diff --git a/.gitattributes b/.gitattributes index cc784984976..e0e0282b69f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -47,6 +47,9 @@ # Generated method->params catalog: compared byte-for-byte by # verify:rpc-params-catalog, so a CRLF checkout would fail the gate. /src/shared/rpc-contract/rpc-params-catalog.generated.ts linguist-generated=true text eol=lf +# Mobile RPC recording goldens: replay requires the file text to equal what rpc:record writes +# (LF), so a CRLF checkout would fail every golden. +/mobile/rpc-foundation/goldens/*.json text eol=lf # Mobile web page source. Every text byte here is hashed into an asset digest and from # there into buildId, so a CRLF Windows checkout would ship a different bundle id for # identical source (91af2897 vs 9d78435e, measured on the bundle this replaced). diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 326de25e153..7cfc408ebf2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,7 @@ ## Linked Issue - +_If you do not have one and are an outside contributors, your PR **wiil** be ignored. Refs is not sufficient. Link an actual issue_ diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index bc9f8241f94..8835c6876b3 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -8,6 +8,11 @@ on: - reopened paths: - 'mobile/**' + # Why all of src/shared: mobile imports hundreds of its modules, directly and through the RPC + # recordings and the host params check, so any shared edit can move a golden or break mobile's + # typecheck. Why the root lockfile: those modules resolve their packages from the root install. + - 'src/shared/**' + - 'pnpm-lock.yaml' # Mobile launch contracts exercise the real host dispatcher and durable receipt store. - 'src/main/agent-launch/**' - 'src/main/runtime/rpc/**' @@ -17,21 +22,6 @@ on: - 'src/main/runtime/orca-runtime.ts' - 'src/main/runtime/agent-session-*.ts' - 'src/main/native-chat/agent-session-wire/**' - - 'src/shared/agent-launch-*.ts' - - 'src/shared/agent-session-*.ts' - - 'src/shared/new-workspace/worktree-create-collision.ts' - # Why: the mobile terminal link parsers are conformance-tested against - # these shared fixtures; desktop-side fixture edits must re-run this suite. - - 'src/shared/terminal-file-link-conformance.ts' - # Why: mobile imports the negotiated capability names directly and records - # the whole capability read verbatim in its goldens, so a capability added - # desktop-side rewrites a mobile fixture and must re-run this suite. - - 'src/shared/protocol-version.ts' - # Why: mobile's rpc-params-contract.ts is a type-only re-export of the - # generated params catalog, and mobile/tsconfig.json includes **/*.ts. A - # schema edit anywhere under here changes mobile's types, so a desktop-only - # change can break mobile's typecheck with no other mobile signal. - - 'src/shared/rpc-contract/**' # Why: this job holds the only checks that load the Fastfile, so edits to # it or to the release workflow it guards must re-run them. - '.github/workflows/mobile.yml' @@ -40,20 +30,30 @@ on: - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' - 'config/scripts/pr-code-change-scope.mjs' - - 'config/scripts/mobile-recording-pin-checkout.test.mjs' - # Why main too: a squash is where a spliced corpus lands, and where a behaviour-change branch's - # own pinned commit leaves main's history for its pull request's head ref, which the guard follows. + # Why main too: two pull requests can each pass against their own base and disagree once both + # land, and `verify` never runs on main. The same source paths as above, less the CI inputs only + # `verify` reads. push: branches: - main paths: - 'mobile/**' + - 'src/shared/**' + - 'pnpm-lock.yaml' + - 'src/main/agent-launch/**' + - 'src/main/runtime/rpc/**' + - 'src/main/runtime/runtime-rpc/**' + - 'src/main/runtime/runtime-rpc.ts' + - 'src/main/runtime/device-registry.ts' + - 'src/main/runtime/orca-runtime.ts' + - 'src/main/runtime/agent-session-*.ts' + - 'src/main/native-chat/agent-session-wire/**' - '.github/workflows/mobile.yml' concurrency: # Per commit on main, not per branch. GitHub cancels any PENDING run in a group when a new one # queues, whatever `cancel-in-progress` says, so one shared main group drops the middle merge of - # three -- and a pin that breaks there is exactly what this workflow now checks for. + # three -- and a merge that breaks main there is exactly what `main-tests` checks for. group: mobile-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true @@ -151,6 +151,14 @@ jobs: ORCA_BACKGROUND_LAUNCH: '1' run: pnpm test + # Reports, never gates: the goldens are content-addressed JSON, so their raw diff is hashes. + # This decodes which recorded behaviour the pull request moves into the job summary, and runs + # after a red Test too, when a reviewer most wants it. + - name: Summarize RPC recording changes + if: ${{ !cancelled() }} + continue-on-error: true + run: pnpm run rpc:diff HEAD^1 --summary "$GITHUB_STEP_SUMMARY" + - name: Test iOS release version resolution if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/ios_release_version_test.rb @@ -177,14 +185,11 @@ jobs: - name: Check formatting run: pnpm format:check - recording-pin: - name: RPC recording pin - # Why ARM: pure Node plus git; the golden comparison masks `platform`. + main-tests: + name: Mobile tests on main + if: github.event_name == 'push' + # Why ARM: the same pure-Node Vitest run `verify` makes on a pull request. runs-on: ubuntu-24.04-arm - # Why pull-requests: the guard asks GitHub which pull requests hold a pin main's history lacks. - permissions: - contents: read - pull-requests: read defaults: run: @@ -194,13 +199,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # The reachability verdict is read straight off history. On a shallow checkout - # `git merge-base --is-ancestor` answers from grafted parents, so the guard refuses to - # answer at all rather than reporting a pass it has no evidence for -- and the pinned tree - # below has to be checkable out. - fetch-depth: 0 - # Ancestry needs commits; the pinned worktree fetches its historical blobs on demand. - filter: blob:none + persist-credentials: false - uses: ./.github/actions/install-node-dependencies with: @@ -211,31 +210,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Seconds. No `--ref`, so the pin is judged against the same tree it was read out of. On a - # pull request that is the merge preview, which already carries main's repins; judging the - # branch head instead fails every branch cut before the day's repin, and its instruction would - # tell the author to pin their own head. A branch that pins its own commit still passes on the - # push after the squash: the guard asks GitHub which pull requests hold the pin and fetches - # their `refs/pull//head`, which GitHub keeps for good. The token lifts the API rate limit. - - name: Check the recording pin is reachable - shell: bash + # The whole suite, not only the recordings: the mutant and page-bridge suites read the goldens + # too. A red run here names each golden and the `rpc:record` command; the author of the merge + # that turned it red re-records in a follow-up. + - name: Test env: - GITHUB_TOKEN: ${{ github.token }} - run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable - - # ~2 min locally for the record itself, so it is gated rather than run twice over. A pull - # request that moves none of the corpus, the manifest or the recorder cannot move this - # verdict away from the one the base commit already published, and `verify` replays the - # corpus against the branch tree in the meantime. A push to main has no `verify` job and is - # where a squash lands a spliced corpus, so there it always runs. - - name: Reproduce the corpus from the pinned tree - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - PIN_GUARD_BASE: ${{ github.event.pull_request.base.sha }} - run: | - if [ -n "$PIN_GUARD_BASE" ]; then - pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce --if-changed-since "$PIN_GUARD_BASE" - else - pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce - fi + ORCA_BACKGROUND_LAUNCH: '1' + run: pnpm test diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 795beca131e..5e06fc6869e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -439,6 +439,8 @@ jobs: runs-on: ubuntu-24.04-arm env: CODEX_CLI_VERSION: '0.150.1' + # Why a second pin: --no-daemon only exists from 0.156, and Orca's codex wrapper relies on it. + CODEX_NO_DAEMON_CLI_VERSION: '0.158.0' steps: - name: Checkout @@ -467,6 +469,34 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/codex/codex-index-heal-binary-contract.test.ts + - name: Install pinned no-daemon Codex CLI + run: | + set -euo pipefail + npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli-no-daemon" \ + "@openai/codex@$CODEX_NO_DAEMON_CLI_VERSION" + + - name: Verify Codex --no-daemon contract + env: + ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED: '1' + ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION: ${{ env.CODEX_NO_DAEMON_CLI_VERSION }} + run: | + set -euo pipefail + ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/pty/codex-no-daemon-binary-contract.test.ts + + # Why the no-daemon install: it is the newest pinned Codex, and its project + # lookup decides whether a worktree launch stops at the trust prompt. + - name: Verify Codex project-trust contract + env: + ORCA_CODEX_TRUST_CONTRACT_REQUIRED: '1' + ORCA_CODEX_TRUST_CONTRACT_VERSION: ${{ env.CODEX_NO_DAEMON_CLI_VERSION }} + run: | + set -euo pipefail + ORCA_CODEX_TRUST_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/agent-trust-presets.test.ts + xterm_patch_sync: name: xterm patch sync needs: [code_paths] @@ -513,6 +543,8 @@ jobs: # and its fish lane is the only end-to-end guard for #9993, so a skip would # report green with nothing exercised. Turns those skips into failures. ORCA_REQUIRE_FISH: '1' + # Why: the runner image ships pwsh, and the codex wrapper's PowerShell lane must not skip. + ORCA_REQUIRE_PWSH: '1' steps: - name: Checkout @@ -607,6 +639,7 @@ jobs: src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/codex-shell-launch-preflight.test.ts \ + src/main/pty/codex-shell-no-daemon.test.ts \ src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ diff --git a/AGENTS.md b/AGENTS.md index 8ac5cd365cc..5aa9295c71e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ Avoid type assertions except `as const`. Unavoidable casts need a line-specific - **Test**: `pnpm test [path/to/file.test.ts]` - **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format` - **Design system**: `pnpm run lint:design-system` for the full renderer report (not a gate); the changed-lines gate above is what CI enforces +- **Real Claude CLI**: when you change Claude structured-session code (`src/main/claude/claude-structured-*`), run `ORCA_REAL_CLAUDE_CLI_TEST=1 pnpm test src/main/claude/claude-structured-real-cli.test.ts src/main/claude/claude-structured-real-cli-fold.test.ts`; it uses your real Claude login # Writing Pull Requests diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts index 02d26f20b3e..b84ebb17893 100644 --- a/cloud/apps/push/src/durable-push-store.ts +++ b/cloud/apps/push/src/durable-push-store.ts @@ -2,13 +2,14 @@ import { isDismissedAlert, reconcileQueuedDismissal } from './push-queued-dismis import { parsePushDeliveryPayload } from './push-delivery-payload.js' import { createHash, randomUUID } from 'node:crypto' import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract' +import { WORKER_DRAINS } from './push-worker-concurrency.js' import type { PushDatabase, SqlRow } from './push-database.js' const RETENTION_MS = 24 * 60 * 60_000 // accept() caps expires_at at due_at + TTL, so older due_at is expired: scans skip an unpruned backlog. const TTL_MS = PUSH_LIMITS.notificationTtlSeconds * 1000 -// Covers one claimer per worker drain skipping a device another drain holds. -const CLAIM_CANDIDATE_ATTEMPTS = 4 +// One winner plus every other drain holding a device head, so a full set of peers cannot exhaust it. +const CLAIM_CANDIDATE_ATTEMPTS = WORKER_DRAINS export const PRUNE_BATCH_ROWS = 2_000 export const PRUNE_MAX_BATCHES = 50 export const DELIVERY_LEASE_MS = 30_000 diff --git a/cloud/apps/push/src/durable-push-worker.ts b/cloud/apps/push/src/durable-push-worker.ts index 64020d3846e..f5bcc20fc2c 100644 --- a/cloud/apps/push/src/durable-push-worker.ts +++ b/cloud/apps/push/src/durable-push-worker.ts @@ -1,6 +1,7 @@ import { buildPushDelivery } from './push-delivery-message.js' import type { PushDispatcher } from './push-dispatcher.js' import type { DurablePushStore } from './durable-push-store.js' +import { WORKER_DRAINS } from './push-worker-concurrency.js' export class DurablePushWorker { private timer?: NodeJS.Timeout @@ -29,7 +30,8 @@ export class DurablePushWorker { return } if (this.stopped) return - const pending = Promise.allSettled(Array.from({ length: 4 }, () => this.drain())).then( + const drains = Array.from({ length: WORKER_DRAINS }, () => this.drain()) + const pending = Promise.allSettled(drains).then( (results) => { const failure = results.find((result) => result.status === 'rejected') if (failure?.status === 'rejected') throw failure.reason diff --git a/cloud/apps/push/src/push-worker-concurrency.ts b/cloud/apps/push/src/push-worker-concurrency.ts new file mode 100644 index 00000000000..daec5659d21 --- /dev/null +++ b/cloud/apps/push/src/push-worker-concurrency.ts @@ -0,0 +1,3 @@ +// Twelve drains lift the ~30/s ceiling four drains hit at ~120 ms per item; each drain holds one +// delivery in flight, so this is the worker's concurrency, not its database draw. +export const WORKER_DRAINS = 12 diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 3572a69299c..9a5cd9d4165 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -8482,7 +8482,7 @@ function isDatabaseLockUnavailable(error: unknown): boolean { return error instanceof Error && error.message === 'database_lock_unavailable' } -export function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { +function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { if (mode === 'nowait') return { failIfUnavailable: true, measureHoldMs: true } if (mode === 'pool-default') return { measureHoldMs: true } return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts deleted file mode 100644 index e26d8d0551e..00000000000 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { cellInventoryLockOptions, type CellInventoryLockMode } from './assignment-store.js' - -// Which entry points can reach a call site. A site a sweep can enter must never -// take the bounded wait: its 55P03 becomes a terminal transaction failure, and -// the incident monitor freezes on a single one. -type Reachability = 'request' | 'sweep' | 'both' | 'orphan' - -// 'caller' is not a CellInventoryLockMode: those sites take the mode threaded -// from `assign`, which is 'request' for a client and 'pool-default' for the -// evacuateDeadCells sweep. -type CensusMode = CellInventoryLockMode | 'caller' - -type CensusEntry = { method: string; mode: CensusMode; reach: Reachability } - -// Every lockCellInventory / lockGeneralCellInventory call site in -// assignment-store.ts, in source order. A new site fails this test until it is -// classified here, which is the point. -const CENSUS: CensusEntry[] = [ - // assignStickyOnce is gone from this list: its retry now locks only the row - // the host is pinned to (lockCellRows), which is what a sticky refresh - // touches. Placement below is the one genuinely fleet-wide decision left. - { method: 'assignOnce', mode: 'caller', reach: 'both' }, - { method: 'assignOnce', mode: 'caller', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' }, - // changeActivity, acquireActivity, activateControl and - // removeSupersededSameCellControls no longer take the inventory: they lock - // only the one or two cell rows they touch, in cell_id order (lockCellRows), - // so they cannot cycle with placement's ordered inventory lock, and the - // 23-row lock there had serialised every reconnect in the fleet behind every - // other one. The control accept path went one step further and takes no cell - // read lock at all: its single conditional write is the last statement before - // COMMIT. - { method: 'startEvacuation', mode: 'request', reach: 'request' }, - { method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' }, - { method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' }, - { method: 'supersedeRegisteredEvacuationOnce', mode: 'request', reach: 'request' }, - { method: 'supersedeRegisteredEvacuationOnce', mode: 'nowait', reach: 'request' }, - { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, - { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, - { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, - { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, - { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, - // startRegionalRehomeCandidate is gone: the rehome commit reads the inventory - // unlocked and locks only its target row, NOWAIT, as the statement before - // COMMIT (reserveRegionalRehomeTargetRow below). - { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - // Both regional-rehome abort sweeps share this rollback; only the 24-hour - // one also disables the durable switch. - { method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' } - // reconcileReservationAccounting and leastLoadedCell are gone too: the first - // repairs exactly two cells' counters and now holds only those rows, and the - // second selects from the inventory its single caller has already locked. -] - -// Every inline `FROM relay_cells ... FOR UPDATE` outside the named lock helpers, -// in source order: whole-table locks in reconciliation and sticky placement, -// and single-row locks for a cell the method is already scoped to (heartbeat, -// fence, drain generation, configuration, or a reservation adjust that runs -// under a lock its caller already holds). A new inline lock fails the census -// below until it is listed here; per-connection paths that touch more than one -// cell go through lockCellRows so the order is fixed. -const NAMED_LOCK_HELPERS = ['lockCellInventory', 'lockGeneralCellInventory', 'lockCellRows'] - -const INLINE_CELL_LOCK_SITES = [ - 'reconcileCellsWithOptions', - 'assignStickyOnce', - 'recordCellHeartbeat', - 'attestCellFence', - 'adoptLegacyCellFence', - 'commitLegacyCellFenceAdoption', - 'prepareCellFenceAttempt', - 'attestCellFenceAttempt', - 'attestCellFenceAttempt', - 'configureCell', - 'reserveRegionalRehomeTargetRow', - 'assertDrainCellGeneration', - 'adjustCellReservation' -] - -// The background sweeps, and nothing else. A method reachable from one of these -// can be entered by a sweep tick, whatever else can also enter it. Both lists are -// read from source, so a new sweep step or a new route widens the derivation here -// instead of silently widening what a bounded wait can be entered from. -const SWEEP_ENTRY_FILES = ['./assignment-cleanup-steps.ts', './regional-rehome-worker.ts'] -const REQUEST_ENTRY_FILES = [ - './app.ts', - './relay-server.ts', - './host-session-registry.ts', - './cell-admission-startup.ts' -] - -const DECLARATION = /^ {2}(?:private |public )?(?:static )?(?:async )?([A-Za-z_][\w]*)[(<]/ - -function storeSource(): string[] { - return readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8').split('\n') -} - -function entryPoints(files: string[]): string[] { - return files.flatMap((file) => - [ - ...readFileSync(new URL(file, import.meta.url), 'utf8').matchAll( - /assignments\.([A-Za-z_][\w]*)\(/g - ) - ].map((call) => call[1]!) - ) -} - -// Same-class call graph: store methods only ever reach each other through `this.`. -function storeCallGraph(lines: string[]): Map> { - const bounds: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) bounds.push({ name: declaration[1]!, start: index }) - }) - const callees = new Map>() - bounds.forEach((method, index) => { - const end = bounds[index + 1]?.start ?? lines.length - const names = callees.get(method.name) ?? new Set() - for (const call of lines - .slice(method.start, end) - .join('\n') - .matchAll(/this\.([A-Za-z_][\w]*)\s*\(/g)) { - names.add(call[1]!) - } - callees.set(method.name, names) - }) - return callees -} - -function closure(callees: Map>, roots: string[]): Set { - const reached = new Set() - const pending = [...roots] - while (pending.length > 0) { - const name = pending.pop()! - if (reached.has(name)) continue - reached.add(name) - for (const callee of callees.get(name) ?? []) if (!reached.has(callee)) pending.push(callee) - } - return reached -} - -// Why: a hand-written reachability column is a claim, not a check. Derive both -// directions, so a new sweep edge into a bounded site fails here instead of in -// production, and so 'sweep' and 'both' stop being asserted by hand. -function derivedReachability(lines: string[]): (method: string) => Reachability { - const callees = storeCallGraph(lines) - const sweep = closure(callees, entryPoints(SWEEP_ENTRY_FILES)) - const request = closure(callees, entryPoints(REQUEST_ENTRY_FILES)) - return (method) => - sweep.has(method) - ? request.has(method) - ? 'both' - : 'sweep' - : request.has(method) - ? 'request' - : 'orphan' -} - -function readCallSites(): { method: string; mode: CensusMode }[] { - const sites: { method: string; mode: CensusMode }[] = [] - let method = '' - for (const line of storeSource()) { - const declaration = DECLARATION.exec(line) - if (declaration) method = declaration[1]! - if (/private async lock(General)?CellInventory\(/.test(line)) continue - const call = /lock(?:General)?CellInventory\(\s*\w+\s*,\s*(?:'([a-z-]+)'|(\w+))\s*\)/.exec(line) - if (!call) continue - sites.push({ method, mode: (call[1] ?? 'caller') as CensusMode }) - } - return sites -} - - -// Tier 3 and tier 4 of the row lock order documented in assignment-store.ts. A -// transaction that takes relay_cells before this host's reservation rows can -// cycle with one that takes them the other way round, and PostgreSQL resolves -// that as a 40P01 during exactly the drain and rehome waves these paths exist -// to run. The cell row is the one every host on a cell shares, so it is the -// lock that must be taken last, which fixes the direction for everyone else. -const CELL_LOCK_CALL = - /this\.(?:lockCellInventory|lockGeneralCellInventory|lockCellRows|adjustCellReservationAtomically|adjustCellReservation)\(|UPDATE relay_cells/ -const RESERVATION_LOCK_CALL = - /this\.(?:lockControlConnectionReservations|insertControlConnectionReservation|claimControlConnectionReservation|releaseSupersededControlConnectionReservations)\(|(?:UPDATE|INTO|DELETE FROM)\s+relay_control_connection_reservations/ - -// The lock helpers themselves, plus the one reporting query that reads both -// tables without locking either. -const ROW_LOCK_ORDER_EXEMPT = [ - 'lockCellInventory', - 'lockGeneralCellInventory', - 'lockCellRows', - 'lockControlConnectionReservations', - 'adjustCellReservation', - 'adjustCellReservationAtomically', - 'insertControlConnectionReservation', - 'claimControlConnectionReservation', - 'releaseSupersededControlConnectionReservations', - 'cellDeploymentStatus' -] - -function methodSpans(lines: string[]): { name: string; start: number; end: number }[] { - const starts: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) starts.push({ name: declaration[1]!, start: index }) - }) - return starts.map((entry, index) => ({ - ...entry, - end: starts[index + 1]?.start ?? lines.length - })) -} - -function pathsTakingCellsBeforeReservations(lines: string[]): string[] { - const offending: string[] = [] - for (const span of methodSpans(lines)) { - if (ROW_LOCK_ORDER_EXEMPT.includes(span.name)) continue - let cell = Number.POSITIVE_INFINITY - let reservation = Number.POSITIVE_INFINITY - for (let index = span.start; index < span.end; index++) { - const line = lines[index]! - if (CELL_LOCK_CALL.test(line)) cell = Math.min(cell, index) - if (RESERVATION_LOCK_CALL.test(line)) reservation = Math.min(reservation, index) - } - if (cell < reservation && reservation !== Number.POSITIVE_INFINITY) { - offending.push(span.name) - } - } - return offending -} - -describe('cell inventory lock call-site census', () => { - it('classifies every call site exactly as recorded', () => { - expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode }))) - }) - - // Why: the census only sees lockCellInventory calls, so a hand-written - // `relay_cells ... FOR UPDATE` would escape classification entirely. - it('routes every relay_cells row lock through a named lock helper', () => { - const lines = storeSource() - const rawSites: string[] = [] - // Whole statements, not a fixed window: a wide column list or a raw - // FOR UPDATE inside query() must not slip past. - const source = lines.join('\n') - const bounds: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) bounds.push({ name: declaration[1]!, start: index }) - }) - const methodAt = (offset: number): string => { - const lineIndex = source.slice(0, offset).split('\n').length - 1 - let name = '' - for (const bound of bounds) if (bound.start <= lineIndex) name = bound.name - return name - } - const tick = String.fromCharCode(96) - const statementCall = new RegExp( - '\\.(queryLocked|query)\\(\\s*' + tick + '([^' + tick + ']*)' + tick, - 'g' - ) - for (const call of source.matchAll(statementCall)) { - const statement = call[2]! - if (!/\bFROM\s+relay_cells\b/.test(statement)) continue - const locks = call[1] === 'queryLocked' || /\bFOR\s+UPDATE\b/.test(statement) - if (!locks) continue - const method = methodAt(call.index) - if (NAMED_LOCK_HELPERS.includes(method)) continue - rawSites.push(method) - } - expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES) - }) - - it('takes the host reservation rows before the shared cell row everywhere', () => { - expect(pathsTakingCellsBeforeReservations(storeSource())).toEqual([]) - }) - - it('leaves no call site taking the inventory without naming a mode', () => { - const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8') - const unclassified = source - .split('\n') - .filter((line) => /lock(?:General)?CellInventory\(\s*\w+\s*\)/.test(line)) - .filter((line) => !line.includes('private async')) - - expect(unclassified).toEqual([]) - }) - - it('derives the same reachability the census claims', () => { - const reachOf = derivedReachability(storeSource()) - - expect(readCallSites().map(({ method }) => reachOf(method))).toEqual( - CENSUS.map((entry) => entry.reach) - ) - }) - - // Why: this is the whole point of the classification. A shorter wait on a - // sweep-reachable site turns contention into a terminal transaction failure - // that counts against the incident gate's relayPostgresRetryExhausted bar. - // Why: the hold distribution is what the 500ms bound will be tuned against, so - // a mode that stops asking for it goes unmeasured in exactly the lane that - // matters. Nothing else in the suite reads the pool-default branch. - it('measures the hold in every lock mode', () => { - const modes: CellInventoryLockMode[] = ['request', 'nowait', 'pool-default'] - - expect(modes.map((mode) => cellInventoryLockOptions(mode).measureHoldMs)).toEqual([ - true, - true, - true - ]) - }) - - it('never puts a sweep-reachable site on the bounded wait', () => { - const reachOf = derivedReachability(storeSource()) - const bounded = readCallSites().filter( - (site) => site.mode === 'request' && ['sweep', 'both'].includes(reachOf(site.method)) - ) - - expect(bounded).toEqual([]) - }) - - it('routes every sweep-only site to NOWAIT so it can skip the tick', () => { - const reachOf = derivedReachability(storeSource()) - const queueing = readCallSites().filter( - (site) => reachOf(site.method) === 'sweep' && site.mode !== 'nowait' - ) - - expect(queueing).toEqual([]) - }) -}) diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts index 44a4b990ea8..1bf3153f3e4 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' const fakes = vi.hoisted(() => ({ @@ -78,7 +77,6 @@ describe('bounded cell-inventory lock wait', () => { // Why: a bound at or above the pool default would fence nothing, and one far // below the hold time would convert ordinary contention into terminal failures. it('keeps the request bound strictly inside the pool default', () => { - expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBe(500) expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS) }) @@ -361,15 +359,6 @@ describe('bounded cell-inventory lock wait', () => { await database.close() }) - // Why: index.ts boots a server on import, so its wiring can only be read. An - // unspread hold metric is invisible: the flush simply omits the fields. - it('spreads the hold counts into the runtime metrics flush', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const flush = /observability\.start\(\(\) => \(\{([^}]*)\}\)\)/.exec(source) - - expect(flush?.[1]).toContain('...consumeRelayCellInventoryHold(database)') - }) - // Why: 500ms is a first value, not a measurement. Tuning it needs the hold // distribution, which no runtime metric carried. it('reports how long the inventory lock was held to COMMIT', async () => { @@ -492,25 +481,6 @@ describe('background sweeps skip a contended cell inventory', () => { expect(warnings.entries).toEqual([]) await database.close() }) - - it('still aborts the expired evacuation once the inventory is free', async () => { - const database = await openInMemoryRelayDatabase() - const probe = new InventoryLockProbe(database) - let now = 1_000 - const store = new RelayAssignmentStore(probe, () => now) - await store.reconcileCells(CELLS) - const assignment = await store.assign(identity) - await store.activateControl(identity, { - cellId: assignment.cellId, - assignmentEpoch: assignment.assignmentEpoch, - generation: 1 - }) - await store.startEvacuation(identity, 'cell-b') - now += 24 * 60 * 60_000 - - expect(await store.abortExpiredEvacuations()).toBe(1) - await database.close() - }) }) // Returns each inventory lock the run took, as its bound or 'nowait'. diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts index 79f65a92582..1b72c3ab031 100644 --- a/cloud/apps/relay/src/relay-sweep-schedule.test.ts +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -45,21 +45,6 @@ describe('sweep schedule jitter', () => { expect(timers).toEqual([6_600]) }) - // Why: index.ts boots a server on import, so its wiring can only be read. - it('jitters the director assignment cleanup tick', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const cleanup = /runAssignmentCleanup\(assignments\)\s*\},\s*([^\n]*?)\)\n/.exec(source) - - expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)') - }) - - it('jitters the credential cleanup tick', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const cleanup = /'\[orca-relay\] credential cleanup failed'\s*\),\s*([^\n]*?)\n/.exec(source) - - expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)') - }) - // A census, not a list of the timers that happen to be gated today: an ungated sweep runs in // every cell as well as the director, which multiplies one table scan by the fleet size. it('gates every periodic sweep in index.ts on the maintenance role', () => { diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index 5588bcd27fc..ee30b6340fa 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -2,8 +2,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { parseProductionCapacityCellArguments, - prepareProductionCapacityCell, - PRODUCTION_CAPACITY_CELL_IDS + prepareProductionCapacityCell } from './prepare-relay-production-capacity-canary.mjs' const config = { @@ -63,24 +62,6 @@ function canaryFetch() { describe('production Relay capacity cell admission', () => { it('allows only the serving rollout cells', () => { - assert.deepEqual(PRODUCTION_CAPACITY_CELL_IDS, [ - 'production-gce-c7', - 'production-gce-c8', - 'production-gce-c9', - 'production-gce-c10', - 'production-gce-c13', - 'production-gce-c14', - 'production-gce-c15', - 'production-gce-c16', - 'production-gce-c19', - 'production-gce-c20', - 'production-gce-c21', - 'production-gce-c22', - 'production-gce-c23', - 'production-gce-c24', - 'production-gce-c25', - 'production-gce-c26' - ]) assert.deepEqual(parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', '--cell-origin', 'https://c7.relay.onorca.dev', diff --git a/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs b/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs index d79d4f91046..628b09176da 100644 --- a/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs +++ b/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import { test } from 'node:test' import { LEASED_WORKFLOWS, @@ -21,7 +20,6 @@ import { revisionMintingScripts, workflowFiles } from './cloud-sql-rollout-lock-census.mjs' -import { relayWorkflowFile } from './relay-repository.mjs' const expectedLease = { production: PRODUCTION_LEASE, staging: STAGING_LEASE, selectable: SELECTABLE_LEASE } const leasedFiles = Object.keys(LEASED_WORKFLOWS) @@ -182,29 +180,3 @@ test('census: no workflow rolls out against the shared instance outside the leas assert.ok(!(file in NOT_A_CLOUD_SQL_CANDIDATE), `${file} cannot be both leased and a non-candidate`) } }) - -// The API and auth deploy scripts share this contract but stay in the private repository. -const serviceCapScripts = ['dev/scripts/deploy-relay-blue-green.mjs'] - -test('budgets tagged Cloud Run candidates outside the service-wide instance cap', () => { - for (const file of serviceCapScripts) { - const script = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') - assert.match(script, /'--no-traffic'/, file) - assert.match(script, /'--max'/, file) - } - const budget = readFileSync( - new URL('../../dev/scripts/relay-cloud-sql-connection-budget.mjs', import.meta.url), - 'utf8' - ) - assert.match(budget, /directly addressable tagged revisions outside service-level caps/) - assert.match( - budget, - /apiCandidate: retainedDirectorRollback \+ inputs\.apiInstances \* inputs\.apiPoolMax/ - ) - const director = readWorkflow(relayWorkflowFile('deploy-relay-production-director.yml')) - const capacity = readWorkflow(relayWorkflowFile('deploy-relay-production-capacity-job.yml')) - const asia = readWorkflow(relayWorkflowFile('operate-relay-asia-admission.yml')) - assert.match(director, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/) - assert.match(capacity, /--max-instances 5/) - assert.match(asia, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/) -}) diff --git a/cloud/dev/scripts/push-gateway-workflow.test.mjs b/cloud/dev/scripts/push-gateway-workflow.test.mjs index 3ee4fabe410..5a8349c1855 100644 --- a/cloud/dev/scripts/push-gateway-workflow.test.mjs +++ b/cloud/dev/scripts/push-gateway-workflow.test.mjs @@ -309,11 +309,6 @@ test('push credentials cannot assume the shared Relay deploy identity', () => { assert.doesNotMatch(terraform('push-gateway.tf'), /member\s*=\s*local\.relay_github_deploy_service_account_member/) }) -// A latest revision needs a successor even when validation is inert. -test('dedicated database admits three simultaneous revision pools', () => { - assert.match(terraform('push-gateway.tf'), /var\.push_max_instances \* var\.push_database_pool_max \* 3 <= 64/) -}) - test('push has only a dedicated database attachment and a narrowly scoped deployment lease', () => { const service = terraform('push-gateway.tf') const database = terraform('push-dedicated-database.tf') diff --git a/cloud/dev/scripts/push-validation-workflow.test.mjs b/cloud/dev/scripts/push-validation-workflow.test.mjs deleted file mode 100644 index e26769cfeb3..00000000000 --- a/cloud/dev/scripts/push-validation-workflow.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import { readRelayWorkflow } from './relay-repository.mjs' - -const workflow = readRelayWorkflow('push-deploy.yml') -const position = (name) => { - const index = workflow.indexOf(`- name: ${name}`) - assert.notEqual(index, -1) - return index -} -const capability = position('Require image support for inert validation') -const deploy = position('Deploy the candidate revision with no traffic') -const activation = position('Retire inert validation and activate the verified image') -const shift = position('Shift all traffic to the verified candidate') - -test('the exact build digest must support validation before production boot', () => { - assert.match(workflow, /docker buildx build --push --platform linux\/amd64 --provenance=false --metadata-file/) - assert.match(workflow, /containerimage\.digest/) - assert.doesNotMatch(workflow, /gcloud artifacts docker images describe/) - assert.ok(capability < deploy) - const preflight = workflow.slice(capability, deploy) - assert.match(preflight, /docker run --rm --network none --entrypoint node "\$\{IMAGE\}"/) - assert.match(preflight, /loadPushConfig\(env\)\.mode !== "validation"/) - assert.match(preflight, /validation_mode_not_fail_closed/) -}) - -test('inert validation and credential checks precede deliberate activation of the same digest', () => { - assert.match(workflow.slice(deploy, activation), /--update-env-vars ORCA_PUSH_MODE=validation/) - assert.match(workflow.slice(deploy, activation), /\.mode == "validation"/) - assert.ok(position('Prove the runtime identity can reach FCM') < activation) - const active = workflow.slice(activation, shift) - assert.ok(active.indexOf('gcloud run deploy') < active.indexOf('gcloud run revisions delete')) - assert.match(active, /--image "\$\{IMAGE\}"/) - assert.match(active, /--remove-env-vars ORCA_PUSH_MODE/) - assert.match(active, /\.spec\.containers\[0\]\.image == \$image/) - assert.match(active, /\.spec\.serviceAccountName == \$account/) - assert.match(active, /\.mode == "active"/) - assert.ok(active.indexOf('ACTIVATION_ATTEMPTED=true') < active.indexOf('gcloud run deploy')) - assert.match(workflow, /deletion below must stop its workers/) -}) - -test('production startup connects read-only and gates all background work in validation', () => { - const entry = readFileSync(new URL('../../apps/push/src/index.ts', import.meta.url), 'utf8') - assert.match(entry, /readOnly: config\.mode === 'validation'/) - assert.match(entry, /startPushBackground\(config,/) - assert.doesNotMatch(entry, /worker\.start\(/) -}) diff --git a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs deleted file mode 100644 index 8283d43ca44..00000000000 --- a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs +++ /dev/null @@ -1,375 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { relayWorkflowUrl } from './relay-repository.mjs' - -const workflow = readFileSync( - relayWorkflowUrl('operate-relay-asia-admission.yml'), - 'utf8' -) -const iam = readFileSync(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url), 'utf8') -const stagingProof = readFileSync( - relayWorkflowUrl('prove-relay-asia-staging.yml'), - 'utf8' -) -const directorWorkflow = readFileSync( - relayWorkflowUrl('deploy-relay-production-director.yml'), - 'utf8' -) -const terraformReadme = readFileSync( - new URL('../../infra/terraform/README.md', import.meta.url), - 'utf8' -) -const proofIam = readFileSync( - new URL('../../infra/terraform/relay-asia-proof-iam.tf', import.meta.url), - 'utf8' -) -const relayTerraform = readFileSync( - new URL('../../infra/terraform/relay.tf', import.meta.url), - 'utf8' -) -const rolloutEvidence = readFileSync( - new URL('./relay-asia-rollout-evidence.mjs', import.meta.url), - 'utf8' -) -const admissionBudgets = readFileSync( - new URL('../../packages/relay-contract/src/admission-budgets.ts', import.meta.url), - 'utf8' -) - -test('offers the exact audited admission modes under the shared deployment lock', () => { - for (const mode of [ - 'inspect', 'initialize', 'verify', 'register', 'configure', 'promote', 'rollback' - ]) { - assert.match(workflow, new RegExp(`\\b${mode}\\b`)) - } - assert.match(workflow, /production-cloud-sql-rollout/) - assert.match(workflow, /relay-staging-mutation/) - assert.match(workflow, /selector-generation/) - assert.match(workflow, /selector-attempt-id/) -}) - -test('requires exact confirmations and uses the existing admin identity', () => { - assert.match(workflow, /INITIALIZE_ADMISSION_SELECTOR/) - assert.match(workflow, /REGISTER_ASIA_MIGRATION_ONLY/) - assert.match(workflow, /PROMOTE_ASIA_GENERAL/) - assert.match(workflow, /ROLLBACK_ASIA_MIGRATION_ONLY/) - assert.match(workflow, /CONFIGURE_ASIA_DIRECTOR/) - assert.match(workflow, /GCP_RELAY_DEPLOY_SERVICE_ACCOUNT/) - assert.match(workflow, /id_token_audience: \$\{\{ env\.DIRECTOR_ORIGIN \}\}\/v1\/admin\/drain/) - assert.match(iam, /"operate-relay-asia-admission\.yml"/) -}) - -test('discovers generation read-only and explicitly initializes only generation zero', () => { - assert.match(workflow, /leave empty only for inspect/) - assert.match(workflow, /test -z "\$\{EXPECTED_SELECTOR_GENERATION\}"/) - assert.match(workflow, /test "\$\{EXPECTED_SELECTOR_GENERATION\}" = 0/) - assert.match(workflow, /\^\(0\|\[1-9\]\[0-9\]\*\)\$/) - assert.match(workflow, /selector-membership-sha256/) - assert.match(workflow, /\^\[a-f0-9\]\{64\}\$/) - assert.match(workflow, /director-image-digest/) - assert.match(workflow, /\.spec\.containers\[0\]\.image == \$image/) -}) - -test('uploads one sanitized machine-readable admission result', () => { - assert.match(workflow, /sanitize-relay-asia-admission-result\.mjs/) - const upload = /- name: Upload sanitized admission result\n([\s\S]*?)(?=\n - name:)/ - .exec(workflow)?.[1] - assert.ok(upload) - assert.match( - upload, - /if: \$\{\{ inputs\.mode != 'configure' && steps\.admission-operation\.outcome == 'success' \}\}/ - ) - assert.match(upload, /uses: actions\/upload-artifact@v4/) - assert.match( - upload, - /relay-asia-admission-result-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/ - ) - assert.match(upload, /path: \$\{\{ runner\.temp \}\}\/relay-asia-admission-result\/result\.json/) - assert.match(upload, /if-no-files-found: error/) - assert.match(upload, /retention-days: 7/) - assert.ok( - workflow.indexOf('Upload sanitized admission result') > - workflow.indexOf('Upload immutable canary evidence') - ) -}) - -test('binds selector operations and director configuration to reviewed implementations', () => { - assert.match(workflow, /operate-relay-asia-admission\.mjs/) - assert.match(workflow, /prepare-relay-asia-director-cells\.mjs/) - assert.match(workflow, /deploy-relay-blue-green\.mjs/) - assert.match(workflow, /--prune-revisions false/) - assert.doesNotMatch(workflow, /gcloud secrets versions add/) - assert.match(workflow, /orca-cloud-relay-regional-placement-enabled/) - assert.match(workflow, /\.valueSource\.secretKeyRef/) - assert.match(workflow, /jq -er --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/) - assert.doesNotMatch(workflow, /jq -e --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/) - assert.doesNotMatch(workflow, /--regional-placement-enabled/) - assert.doesNotMatch(workflow, /"\$\{\{ inputs\./) - assert.doesNotMatch(workflow, /dns/i) -}) - -test('requires immutable staged evidence and a timed production canary before expansion', () => { - assert.match(workflow, /actions: read/) - assert.match(workflow, /actions\/download-artifact@v4/) - assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/) - assert.match(workflow, /evidence_kind=staging/) - assert.match(workflow, /load-relay-controls\.mjs/) - assert.match(workflow, /--controls 1/) - assert.match(workflow, /--splices 1/) - assert.match(workflow, /--splice-hold-seconds 60/) - assert.match(workflow, /--relay-asia-load-principals 1/) - assert.match(workflow, /--duration-seconds 300/) - assert.match(workflow, /--required-lease-horizons 2/) - assert.match(workflow, /pnpm\/action-setup@v4/) - assert.match(workflow, /Install exact canary dependencies/) - assert.match(workflow, /pnpm install --frozen-lockfile/) - assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/) - assert.ok( - workflow.indexOf('Build the canary Relay contract') < - workflow.indexOf('Run a real five-minute canary control and splice') - ) - assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/) - assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/) - // C28/C29 promotion downloads C27's canary under exactly this name. - assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/) - assert.match(workflow, /id: canary-evidence-upload/) - assert.match(workflow, /Return an unproven canary cell to migration-only/) - assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/) - assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/) - assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/) - assert.match(workflow, /evidence_kind=c27/) - assert.match(workflow, /orca_relay_runtime_metrics/) - assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/) - assert.match(workflow, /retention-days: 7/) - assert.match(workflow, /Require the exact director image before promotion/) - assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) - assert.match(workflow, /\.imageDigest.*IMAGE_DIGEST/) - const provenance = /- name: Verify evidence provenance and rollout binding before authentication\n([\s\S]*?)(?=\n - id: auth)/ - .exec(workflow)?.[1] - assert.ok(provenance) - assert.match(provenance, /\.head_sha \| select\(type == "string" and test\("\^\[a-f0-9\]\{40\}\$"\)\)/) - assert.match(provenance, /--commit-sha "\$\{evidence_commit_sha\}"/) - assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/) -}) - -test('binds each production promotion wave to its exact evidence and canary', () => { - const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1] - assert.ok(cases) - const waves = Object.fromEntries( - [...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], { - evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none', - canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none' - }]) - ) - assert.deepEqual(waves, { - 'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' }, - 'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' }, - 'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' } - }) - assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/) - assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/) - assert.doesNotMatch(workflow, /inputs\.cell-ids == /) -}) - -test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => { - const steps = workflow.split(/\n(?= - )/) - const named = (name) => steps.find((step) => step.includes(`name: ${name}`)) - for (const name of [ - 'Install exact canary dependencies', - 'Build the canary Relay contract', - 'Verify the canary cell state and start the timed canary', - 'Run a real five-minute canary control and splice', - 'Collect regional, Relay SQL, and Cloud SQL canary evidence', - 'Upload immutable canary evidence' - ]) { - assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name) - } - assert.match( - workflow, - /if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/ - ) - const rollback = named('Return an unproven canary cell to migration-only') - assert.match( - rollback, - /if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/ - ) - assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/) - assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) - assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) - assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/) - const start = named('Verify the canary cell state and start the timed canary') - assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/) - assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/) - assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/) -}) - -test('creates staging evidence only after the bounded launch-path load and rollback', () => { - assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/) - assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/) - assert.match(stagingProof, /pnpm\/action-setup@v4/) - assert.match(stagingProof, /pnpm install --frozen-lockfile/) - assert.match(stagingProof, /pnpm --filter @orca-cloud\/relay-contract build/) - assert.match(stagingProof, /run_phase launch 5 5/) - assert.doesNotMatch(stagingProof, /run_phase control|run_phase mixed/) - assert.match(stagingProof, /--aggregate-controls "\$\(\(controls \* 4\)\)"/) - assert.match(stagingProof, /--aggregate-splices "\$\(\(splices \* 4\)\)"/) - assert.match(stagingProof, /--required-lease-horizons 2/) - assert.match(stagingProof, /--splice-ramp-seconds 120/) - assert.match(stagingProof, /--max-generator-rss-growth-mib 512/) - assert.match(stagingProof, /--relay-asia-load-principals 32/) - assert.match(stagingProof, /ulimit -n/) - assert.match(stagingProof, /--region-behavior-probes 1/) - assert.match(stagingProof, /--capacity-cell-origin https:\/\/c4\.relay-staging\.onorca\.dev/) - assert.match(stagingProof, /--rebind-probes 2/) - assert.match(stagingProof, /--skip-rebind-overflow-check/) - assert.doesNotMatch(stagingProof, /--request-unit-invites|--regional-fallback-probes/) - assert.match(stagingProof, /--aggregate-reader-splices.*echo 5/) - assert.match(stagingProof, /--aggregate-reader-bytes.*echo 12582912/) - assert.match(stagingProof, /--phase-barrier-dir "\$\{proof_dir\}\/\$\{phase\}-barrier"/) - assert.match(stagingProof, /--duration-seconds 210/) - assert.match(stagingProof, /trap stop_shards EXIT/) - assert.match(stagingProof, /if ! wait "\$\{pid\}"; then failed=1; break; fi/) - assert.match(stagingProof, /connectionFailuresByReason/) - assert.match(stagingProof, /--launch-report "\$\{proof_dir\}\/launch\.json"/) - assert.match(stagingProof, /id-token: write/) - assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER/) - assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT/) - assert.doesNotMatch(stagingProof, /STAGING_GCP_DEPLOY_SERVICE_ACCOUNT/) - assert.doesNotMatch(stagingProof, /STAGING_RELAY_LOAD_ACCESS_TOKEN/) - assert.doesNotMatch(stagingProof, /secrets versions access|signing-key-file/) - assert.match(stagingProof, /relay-asia-rollout-evidence\.mjs create-staging/) - assert.match(stagingProof, /Require the exact staging director image before promotion/) - assert.match(stagingProof, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) - assert.match(stagingProof, /\.imageDigest.*IMAGE_DIGEST/) - assert.match(stagingProof, /Return staging C4 to migration-only/) - assert.match(stagingProof, /steps\.promote\.outcome != 'skipped'/) - assert.match(stagingProof, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{PROMOTE_ATTEMPT_ID\}"/) - assert.match(stagingProof, /--mode rollback[\s\S]*?--expected-generation "\$\{promoted_generation\}"/) - assert.match(stagingProof, /if: \$\{\{ success\(\) \}\}/) - assert.match( - stagingProof, - /recover:\n if: \$\{\{ always\(\) && github\.ref == 'refs\/heads\/main' \}\}/ - ) - assert.match(stagingProof, /needs: prove/) - assert.match(stagingProof, /Recover staging C4 with a fresh identity/) - assert.equal((stagingProof.match(/google-github-actions\/auth@v2/g) ?? []).length, 2) - assert.equal((stagingProof.match(/--mode recover-promotion/g) ?? []).length, 2) - assert.equal((stagingProof.match(/--mode rollback/g) ?? []).length, 2) -}) - -test('keeps the private runner below its 64-port Cloud NAT allocation', () => { - const profile = /run_phase launch (\d+) (\d+)/.exec(stagingProof) - const controlsPerShard = Number(profile?.[1]) - const splicesPerShard = Number(profile?.[2]) - const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1]) - const runtimeStatusSockets = 1 - assert.ok( - controlsPerShard * 4 + splicesPerShard * 4 * 2 + rebindProbes + runtimeStatusSockets < 64 - ) -}) - -test('paces one-source staging upgrades below the Relay anti-abuse ceiling', () => { - const splicesPerShard = Number(/run_phase launch \d+ (\d+)/.exec(stagingProof)?.[1]) - const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1]) - const spliceRampMs = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) * 1000 - const ceiling = Number( - /maxPreAuthAttemptsPerSourcePerMinute: (\d+)/.exec(admissionBudgets)?.[1] - ) - const totalSplices = splicesPerShard * 4 - const attempts = Array.from({ length: totalSplices }, (_, ordinal) => - Math.floor(ordinal * spliceRampMs / (totalSplices - 1)) - ).flatMap((startedAt) => [startedAt, startedAt]) - attempts.push(...Array.from({ length: 4 + rebindProbes }, () => 0)) - const busiestMinute = Math.max(...attempts.map((startedAt) => - attempts.filter((attempt) => attempt >= startedAt && attempt < startedAt + 60_000).length - )) - assert.ok(busiestMinute < ceiling) -}) - -test('reserves rollback time beyond the complete bounded staging proof envelope', () => { - const timeoutMinutes = Number(/timeout-minutes: (\d+)/.exec(stagingProof)?.[1]) - assert.equal(timeoutMinutes, 75) - const spliceRampSeconds = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) - const launchSeconds = 180 + spliceRampSeconds + 210 + 60 - const setupEvidenceAndRollbackSeconds = 10 * 60 - const envelopeMinutes = Math.ceil((launchSeconds + setupEvidenceAndRollbackSeconds) / 60) - assert.ok(timeoutMinutes - envelopeMinutes >= 30) - assert.match(stagingProof, /--ramp-seconds 180/) - assert.match(stagingProof, /--duration-seconds 210/) -}) - -test('binds the staging proof to one least-privilege Google identity', () => { - assert.match( - proofIam, - /github_relay_asia_proof_workflow_file = "prove-relay-asia-staging\.yml"/ - ) - assert.match( - proofIam, - /assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_relay_asia_proof_workflow_file\}@refs\/heads\/main'/ - ) - assert.match(proofIam, /assertion\.environment == 'staging'/) - assert.match(proofIam, /assertion\.event_name == 'workflow_dispatch'/) - assert.match(proofIam, /roles\/logging\.viewer/) - assert.match(proofIam, /roles\/monitoring\.viewer/) - assert.match(rolloutEvidence, /readCloudSqlBackends/) - assert.match(rolloutEvidence, /cloudSql: await readCloudSqlBackends/) - assert.doesNotMatch(proofIam, /compute\.|cloudsql\.|secretmanager\.|roles\/editor|roles\/run\./) -}) - -test('keeps the production US-first switch in durable Secret Manager state', () => { - assert.match(directorWorkflow, /options: \[preserve, enable, disable\]/) - assert.match(directorWorkflow, /default: preserve/) - assert.match(directorWorkflow, /gcloud secrets versions add/) - assert.match(directorWorkflow, /preserve\) desired="\$\{current\}"/) - assert.match(directorWorkflow, /--regional-placement-secret-version "\$\{target_version\}"/) - assert.match(directorWorkflow, /test "\$\{CEILING\}" = "\$\{DIRECTOR_MAX_INSTANCES\}"/) - assert.match(directorWorkflow, /orca-cloud-relay-regional-placement-enabled/) - assert.match(directorWorkflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/) - assert.match(directorWorkflow, /\.version \/\/ \.key/) - assert.match(directorWorkflow, /\.secret \/\/ \.name/) - assert.match(workflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/) - assert.doesNotMatch(directorWorkflow, /--regional-placement-enabled/) - assert.doesNotMatch(workflow, /inputs\.regional-placement-enabled/) -}) - -test('prunes incompatible production revisions only when explicitly confirmed', () => { - assert.match( - directorWorkflow, - /prune-incompatible-revisions:[\s\S]*?default: false[\s\S]*?type: boolean/ - ) - assert.match(directorWorkflow, /PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/) - assert.match( - directorWorkflow, - /test "\$\{REGIONAL_PLACEMENT_MODE\}" = preserve[\s\S]*?test "\$\{CONFIRMATION\}" = PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/ - ) - assert.match( - directorWorkflow, - /--prune-revisions "\$\{PRUNE_INCOMPATIBLE_REVISIONS\}"/ - ) -}) - -test('documents the exact regional-placement secret bootstrap before director rollout', () => { - for (const address of [ - 'google_secret_manager_secret.relay_regional_placement_enabled', - 'google_secret_manager_secret_version.relay_regional_placement_enabled', - 'google_secret_manager_secret_iam_member.relay_regional_placement_runtime_accessor', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_accessor[0]', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_adder[0]', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer[0]' - ]) { - assert.match(terraformReadme, new RegExp(address.replaceAll(/[.[\]]/g, '\\$&'))) - } - assert.match(terraformReadme, /Before the first director deployment/) - assert.match(terraformReadme, /Pass the exact environment tfvars/) - // The Cloudflare records left with the apps root; a -var for a variable this root no longer - // declares is a hard error, so no relay procedure may still tell an operator to pass it. - assert.doesNotMatch(terraformReadme, /manage_artifact_dns/) - assert.match(terraformReadme, /exactly these six additions/) - assert.match(terraformReadme, /version metadata/) - assert.match( - relayTerraform, - /resource "google_secret_manager_secret_iam_member" "relay_regional_placement_deploy_viewer"[\s\S]*?role\s+= "roles\/secretmanager\.viewer"/ - ) -}) diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs deleted file mode 100644 index aabf80a65f0..00000000000 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' -import { relayWorkflowUrl } from './relay-repository.mjs' - -function workflow(name) { - return readFileSync( - fileURLToPath(relayWorkflowUrl(name)), - 'utf8' - ) -} - -test('same-cap wrapper is reusable, canary-bound, and sequential', () => { - const wrapper = workflow('deploy-relay-production-same-cap.yml') - const job = workflow('deploy-relay-production-same-cap-job.yml') - assert.match(wrapper, /options: \[verify, canary-apply, batch-apply, rollback\]/) - assert.match(wrapper, /relay-same-cap-canary-\$\{\{ inputs\.canary-run-id \}\}/) - assert.match(wrapper, /needs: \[gate, cell_1\]/) - assert.match(wrapper, /needs: \[gate, cell_2\]/) - assert.match(wrapper, /needs: \[gate, cell_3\]/) - assert.match(job, /on:\n workflow_call:/) - assert.match(job, /c27\|c28\|c29\|c30\)/) - assert.match(job, /EXPECTED_HARD_CAP=3000/) - assert.match(job, /EXPECTED_REGION=asia-east2/) - assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/) - assert.match(job, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/) - assert.match(job, /--argjson protocol "\$\{PREDECESSOR_REHOME_PROTOCOL\}"/) - assert.match(job, /runtime predecessor mismatch fields=/) - // A rollback interrupted between apply and restore must be resumable. - assert.match(job, /ROLLBACK_RESUME=true/) - assert.match(job, /test "\$\{LIVE_IMAGE_DIGEST\}" = "\$\{DESIRED_IMAGE_DIGEST\}"/) - // Resume must skip BOTH the drain (no restart will clear the flag) and the - // apply (state already converged), and prove convergence instead. - assert.match( - job, - /Reversibly isolate and drain only the selected cell\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/ - ) - assert.match( - job, - /Apply only the selected same-cap template and MIG\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/ - ) - assert.match( - job, - /Require converged Terraform state and a stable MIG on resume\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME == 'true' \}\}/ - ) - assert.match(job, /resume found unconverged resources/) - // A canary or batch cell that failed before its template apply also - // resumes here with template drift from repo changes since its last roll; - // only a plan the reviewed validator approves for the image the cell - // already serves may pass, and resume still applies nothing. - assert.match(job, /requiring reviewed rollback-image drift/) - assert.match( - job, - /--image "\$\{DESIRED_IMAGE\}" \\\n {16}--rollback-image "\$\{DESIRED_IMAGE\}"/ - ) - // The relaxation is only safe if the reviewed validator actually runs on - // the NON-converged branch, in same-cap-cell mode, with the trust config - // the validator requires, restricted to the template-and-MIG change pair. - assert.match( - job, - /if ! terraform -chdir=infra\/terraform show -json[\s\S]{0,220}\| length == 0' >\/dev\/null\n then\n/ - ) - assert.match( - job, - /requiring reviewed rollback-image drift'\n[\s\S]{0,400}?\n {16}--mode same-cap-cell --cell-id "\$\{TARGET_CELL_ID\}" \\\n/ - ) - assert.match( - job, - /Require converged Terraform state and a stable MIG on resume[\s\S]{0,300}CAPACITY_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT \}\}\n {10}DIRECTOR_RUNTIME_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_DIRECTOR_RUNTIME_SERVICE_ACCOUNT \}\}/ - ) - assert.match( - job, - /--rollback-image "\$\{DESIRED_IMAGE\}" \\\n {16}--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}" \\\n {16}--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/ - ) - assert.match( - job, - /host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}"\)"\n {12}echo "\$\{RESUME_REVIEW\}"\n {12}jq -e '\.changes == 2' <<< "\$\{RESUME_REVIEW\}" >\/dev\/null/ - ) - // A resume applies nothing at all, which is what a resume means: the only accepted - // unconverged plan is the template-and-MIG rollback-image drift, and it is left pending. - const resumeStep = job.slice( - job.indexOf('- name: Require converged Terraform state and a stable MIG on resume'), - job.indexOf('- name: Apply only the selected same-cap template and MIG') - ) - assert.equal(resumeStep.split('terraform -chdir=infra/terraform apply').length, 1) - assert.match(job, /resume requires the isolated migration-only cell/) - assert.match(job, /test "\$\{TARGET_INCARNATION\}" = "\$\{SOURCE_INCARNATION\}"/) - assert.match(job, /\(.regionalRehomeProtocol \/\/ 0\) == \$protocol/) - assert.match(job, /\(\.draining == false or \$drainingOk\)/) - // Selector expectations must follow the mutations' returned generations, - // not fixed offsets: isolate is a no-op on a cell a failed canary already - // isolated, and the restore inspect must expect post-restore membership. - assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{EFFECTIVE_SELECTOR_GENERATION\}/) - assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{ISOLATE_GENERATION\}/) - assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_ISOLATE\}"/) - assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_RESTORE\}"/) - assert.match(job, /--expected-migration-only-cells "\$\{RESTORED_MIGRATION_CELLS\}"/) - assert.match(job, /--expected-general-cells "\$\{RESTORED_GENERAL_CELLS\}"/) - assert.match(job, /FAILSAFE_GENERATION/) - // Later batch waves start after ~16-min predecessor rolls, so BOTH evidence - // age checks must scale by wave or cell_2+ can never pass; the bound's - // per-wave step is the cell job timeout, so the two must move together. - assert.match(job, /--required-migration-policy strict \\\n --wave-index "\$\{WAVE_INDEX\}"/) - // Wave 0 must retry freshness-only failures too: one Cloud Monitoring publish - // lag at the sample instant is not health evidence, and single-shot wave 0 - // failed a whole batch on a series that was fresh again a minute later. - assert.match( - job, - /dry-run\.state\.json" \\\n {14}--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/ - ) - assert.doesNotMatch(job, /RETRY_ARGS/) - // Break-glass: the override skips the aggregate 15-minute monitor evidence and - // nothing else. The live per-wave recheck still runs on the override path, off - // the dispatch inputs the rehome inspect below verifies against the director. - assert.match( - job, - /if test -n "\$\{GATE_OVERRIDE_CONFIRMATION\}"; then[\s\S]{0,700}?--no-monitor-state \\\n {14}--expected-selector-generation "\$\{EXPECTED_SELECTOR_GENERATION\}" \\\n {14}--selector-membership-file[\s\S]{0,160}?--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/ - ) - // The override is re-validated here, not trusted from the caller, and it is - // bound to the digest this wave installs. - assert.match( - job, - /test "\$\{GATE_OVERRIDE_CONFIRMATION\}" = \\\n {14}"SKIP_RELAY_MONITOR_GATE \$\{TARGET_IMAGE_DIGEST\}"/ - ) - assert.match(job, /\[\[ "\$\{GATE_OVERRIDE_REASON\}" =~ \^\[\[:print:\]\]\{12,500\}\$ \]\]/) - // Exactly the aggregate-evidence steps are skipped, and only them: every step - // that reads or spends the sealed monitor artifact carries the override guard. - const overrideSkipped = [ - 'Require fresh aggregate monitor evidence reference', - 'Download private aggregate monitor evidence', - 'Verify monitor evidence provenance', - "Download this wave's single-use safety authority", - 'Require safety evidence consumed by this workflow' - ] - for (const name of overrideSkipped) { - assert.match( - job, - new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`) - ) - } - assert.equal( - job.match(/inputs\.gate-override-confirmation == ''/g).length, - overrideSkipped.length - ) - // The wrapper validates the override before anything runs, passes it to every - // cell, seals it into the canary artifact, and prints it in the run summary. - assert.match(wrapper, /--gate-override-reason "\$\{GATE_OVERRIDE_REASON\}" \\\n {12}--gate-override-confirmation "\$\{GATE_OVERRIDE_CONFIRMATION\}"\)/) - // One per cell job in the serial cell_1..cell_10 chain. - assert.equal( - wrapper.match(/gate-override-confirmation: \$\{\{ inputs\.gate-override-confirmation \}\}/g).length, - 10 - ) - assert.match(wrapper, /Aggregate monitor gate overridden \(break-glass\)/) - assert.match(wrapper, /ACTOR: \$\{\{ github\.actor \}\}/) - for (const name of [ - 'Reject previously consumed aggregate safety evidence', - 'Consume aggregate safety evidence for this exact wave' - ]) { - assert.match( - wrapper, - new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`) - ) - } - assert.match(job, /timeout-minutes: 75/) - // Both age gates step by the cell job timeout above; the constant is - // duplicated across the two languages, so pin each copy to it. - for (const source of [ - '../../dev/scripts/relay-monitor-evidence.mjs', - '../../apps/relay-ops/src/incident-live-preflight-cli.ts' - ]) { - const body = readFileSync(fileURLToPath(new URL(source, import.meta.url)), 'utf8') - assert.match(body, /WAVE_PREDECESSOR_TIMEOUT_MS = 75 \* 60_000/) - assert.match(body, /\^\[0-9\]\$/) - } - // Aged-evidence replay via job re-runs is fenced: mutations are - // single-dispatch, so a failed cell needs a fresh gate and monitor run. - assert.match(job, /test "\$\{GITHUB_RUN_ATTEMPT\}" = 1/) - for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) { - assert.match(wrapper, new RegExp(`wave-index: '${index}'`)) - } - assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 1\)/) - assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 2\)/) - assert.match(job, /\$region == "us-central1" and \$protocol == 0 and [.]region == null/) - assert.match(job, /[.]regionalRehomeProtocol \/\/ 0/) - assert.match(job, /runtime predecessor normalized legacy fields=/) - assert.match(job, /probe-relay-rehome-trust[.]mjs/) - assert.doesNotMatch(job, /service_account: \$\{\{ vars\.PRODUCTION_GCP_RELAY_(?:DIRECTOR_)?RUNTIME_SERVICE_ACCOUNT/) - assert.doesNotMatch(job, /roles\/iam\.serviceAccountTokenCreator/) -}) - -// Why: the same-cap caller defines release_lease itself, and a caller-defined job presents the -// caller as job_workflow_ref, so the pair must admit the caller alongside its reusable job. -test('shared deploy WIF admits the exact same-cap reusable workflow pair and the caller itself', () => { - const terraform = readFileSync( - fileURLToPath(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url)), - 'utf8' - ) - const providerStart = terraform.indexOf( - 'resource "google_iam_workload_identity_pool_provider" "github"' - ) - const providerEnd = terraform.indexOf('\nresource "', providerStart + 1) - const sharedProvider = terraform.slice(providerStart, providerEnd) - assert.ok(providerStart >= 0 && providerEnd > providerStart) - assert.match(sharedProvider, /local\.relay_github_workflow_conditions\["github"\]/) - // The pairing itself now lives in the clause the provider renders, once per accepted repository. - assert.match( - terraform, - /assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main' && \(assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_job_workflow_file\}@refs\/heads\/main' \|\| assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main'\)/ - ) -}) - -test('pause and disable precede optional installation and cloud diagnostics', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - const emergency = job.indexOf('Apply emergency durable pause or disable before diagnostics') - const install = job.indexOf('pnpm install --frozen-lockfile') - const revision = job.indexOf('Verify exact serving and rollback director identities') - assert.ok(emergency > 0) - assert.ok(emergency < install) - assert.ok(emergency < revision) - assert.match(job, /inputs\.mode == 'pause' \|\| inputs\.mode == 'disable'/) - assert.match(job, /Seal 24-hour aggregate region observation evidence/) - assert.match(job, /--freshness=25h --limit=30000/) - assert.match(job, /relay-region-observation-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(job, /test "\$\{RATE_PER_MINUTE\}" = 10/) -}) - -test('a failed enable independently restores and verifies durable disabled state', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - const enable = job.indexOf('Apply exact durable regional rehome enable') - const evidence = job.indexOf('Read fresh aggregate completion and abort evidence') - const summary = job.indexOf('Publish aggregate control evidence') - const recovery = job.indexOf('Fail closed after an unsuccessful enable run') - assert.ok(enable > 0 && enable < evidence && evidence < summary && summary < recovery) - const recoveryStep = job.slice(recovery) - assert.match( - recoveryStep, - /failure\(\) && inputs\.mode == 'enable' && steps\.google-auth\.outcome == 'success'/ - ) - assert.match(recoveryStep, /--mode recover-enable/) - assert.match(recoveryStep, /--expected-control-generation "\$\{EXPECTED_CONTROL_GENERATION\}"/) - assert.match(recoveryStep, /RECOVER_FAILED_REGIONAL_REHOME_ENABLE/) - assert.match(recoveryStep, /\.control\.enabled == false/) - assert.doesNotMatch(recoveryStep, /gcloud|pnpm/) -}) - -test('director rollout has a strict one-time identity bootstrap', () => { - const workflowBody = workflow('deploy-relay-production-director.yml') - const script = readFileSync( - fileURLToPath(new URL('./deploy-relay-blue-green.mjs', import.meta.url)), - 'utf8' - ) - assert.match(workflowBody, /BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY/) - assert.match(workflowBody, /--predecessor-runtime-service-account/) - assert.match(workflowBody, /--expected-rehome-generation/) - assert.match(script, /args\.push\('--service-account', config\['runtime-service-account'\]\)/) - assert.match(script, /director predecessor runtime service account does not match/) - const candidateProof = script.indexOf('await verifyRehomeDisabled(candidate.origin)') - const trafficMove = script.indexOf('operations.updateTraffic(config, [`--to-tags=') - assert.ok(candidateProof > 0 && candidateProof < trafficMove) - assert.equal(script.indexOf('verifyRehomeDisabled', trafficMove), -1) -}) - -test('rehome job pipes every control result through tee under pipefail', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - // Without `shell: bash` the step exit code is tee's, so a thrown inspect/apply passes green. - assert.match(job, /defaults:\n run:\n(?: #.*\n)* shell: bash\n/) - assert.ok((job.match(/\| tee "\$\{RUNNER_TEMP\}/g) ?? []).length >= 5) -}) diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 1e023ce0c9d..ff2c265ee72 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -13,7 +13,6 @@ import { readRelayWorkflow } from './relay-repository.mjs' import { validateCapacityPlan } from './validate-relay-capacity-plan.mjs' const workflow = readRelayWorkflow('deploy-relay-production-same-cap-job.yml') -const capacityWorkflow = readRelayWorkflow('deploy-relay-production-capacity-job.yml') const production = readFileSync( new URL('../../infra/terraform/environments/production.tfvars', import.meta.url), 'utf8' @@ -295,74 +294,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(resolveCellShape('production-gce-c31').status, 1) }) - it('passes the same-cap allowlist on every canary invocation the job runs', () => { - const invocations = workflow.split('prepare-relay-production-capacity-canary.mjs').slice(1) - assert.equal(invocations.length, 4) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.endsWith('\\')) - const call = lines.slice(0, end + 1).join(' ') - assert.match(call, /--approved-cells same-cap/) - // The restore call picks its mode from the cell's entry admission class. - assert.match(call, /--mode (isolate|drain|activate|"\$\{RESTORE_MODE\}")/) - } - }) - it('paces the drain it sends to the selected cell', () => { - const drain = workflow.split('--mode drain')[1] ?? '' - assert.match(drain.split('\n').slice(0, 2).join(' '), /--pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/) - // 5 min is the cell's DRAIN_PACE_WINDOW_MAX_MS; a 2,700-host cell at 2 min overruns the director's sticky lane. - assert.match(workflow, /DRAIN_PACE_WINDOW_MS: '300000'/) - // The transition wait has to outlast the pacing window on top of the leases it waits on. - assert.match(workflow, /--activity restart-safe[\s\S]*?--timeout-ms 1200000/) - }) - it('passes this cell\'s rehome protocol and pool on every plan validation the job runs', () => { - const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1) - assert.equal(invocations.length, 2) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - const call = lines.slice(0, end + 1).join(' ') - assert.match(call, /--mode same-cap-cell/) - assert.match(call, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/) - assert.match(call, /"\$\{POOL_ARGUMENTS\[@\]\}"/) - } - // Each of those steps must build the flag from the resolved pool, and only when there is one. - const builders = workflow.split( - 'if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then\n' + - ' POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")' - ) - assert.equal(builders.length, 3) - assert.equal(workflow.split('POOL_ARGUMENTS=()').length, 3) - }) - // One cell's compute path and nothing else: the template and the MIG bound to it. The cell - // backend service stays out because the capacity role has no compute.backendServices.update, - // so naming it fails the apply after the MIG has already rolled. - it('targets exactly this cell template and MIG on every plan the job runs', () => { - const plans = workflow.split('terraform -chdir=infra/terraform plan').slice(1) - assert.equal(plans.length, 2) - for (const plan of plans) { - const lines = plan.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - const call = lines.slice(0, end + 1).join('\n') - assert.deepEqual( - [...call.matchAll(/-target=([\w.]+)\[\\"\$\{TARGET_CELL_ID\}\\"\]/g)] - .map(([, resource]) => resource), - [ - 'google_compute_instance_template.relay_gce_cell', - 'google_compute_instance_group_manager.relay_gce_cell' - ] - ) - // Any target that is not one of those two, or not scoped to this cell, fails here. - assert.equal(call.split('-target=').length, 3) - } - }) - it('never names a backend service on any plan or apply in the job', () => { - assert.equal(workflow.includes('google_compute_backend_service'), false) - }) it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) @@ -569,25 +504,6 @@ describe('same-cap roll scripts accept every same-cap cell', () => { ) }) - it('pins the capacity identity on every plan validation the job runs', () => { - const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1) - assert.equal(invocations.length, 2) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - assert.match( - lines.slice(0, end + 1).join(' '), - /--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}"/ - ) - } - // Both steps must read it from the same repository variable the job already requires. - assert.equal( - workflow.split( - 'CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}' - ).length, - 4 - ) - }) it('decides the predecessor draining rule from the real block, for both classes', () => { // A zero-host cell sheds nothing, and a failed canary's own drain leaves the flag set @@ -777,29 +693,5 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(rolls('resume', { changes: 0 }), 'no-replace') assert.equal(rolls('none', { changes: 0 }), 'no-replace') }) - - it('waits on the image a stranded cell actually serves', () => { - const isolate = workflow - .split('name: Reversibly isolate and drain only the selected cell')[1] - .split('\n - id:')[0] - assert.match(isolate, /--expected-image-digests "\$\{PREDECESSOR_IMAGE_DIGEST\}"/) - // A stranded cell has to come back on a new process, which is what clears the drain. - const after = workflow - .split('name: Verify new incarnation, exact image, protocol, and durable safety')[1] - .split('\n - name:')[0] - assert.match(after, /test "\$\{TARGET_INCARNATION\}" != "\$\{SOURCE_INCARNATION\}"/) - assert.match(after, /if test "\$\{ROLLBACK_RESUME\}" = true; then/) - }) - - it('leaves the US-only capacity job on the default allowlist', () => { - assert.doesNotMatch(capacityWorkflow, /--approved-cells/) - }) }) -// Both trusted versions must prove the same authenticated drain boundary. -it('proves rehome trust for protocol 3 on forward and rollback rolls', () => { - const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0] - assert.match(step, /inputs\.rollback-rehome-protocol != '0'/) - assert.match(step, /inputs\.target-rehome-protocol != '0'/) - assert.match(step, /probe-relay-rehome-trust\.mjs/) -}) diff --git a/cloud/docs/push-gateway.md b/cloud/docs/push-gateway.md index 319ea4a7f19..77aba744963 100644 --- a/cloud/docs/push-gateway.md +++ b/cloud/docs/push-gateway.md @@ -23,7 +23,7 @@ edit plus a second set of Apple credentials. | Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` | | Region | `us-central1` | `region` | | Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` | -| Database pool | 2 per instance | `push_database_pool_max` | +| Database pool | 6 per instance | `push_database_pool_max` | | Concurrency | 80 | `push_concurrency` | | Ingress | all | `INGRESS_TRAFFIC_ALL` | | Invoker | IAM disabled | `invoker_iam_disabled = true` on the service | @@ -36,8 +36,8 @@ cold start delays a notification past the point where it is worth showing, so th keeps a notification prompt. The ceiling is a different question, answered below. -Push uses its approved dedicated two-vCPU HA database. Two instances with a two-connection -pool draw four connections; three simultaneous revision resources draw twelve. Tagged +Push uses its approved dedicated two-vCPU HA database. Two instances with a six-connection +pool draw twelve connections; three simultaneous revision resources draw thirty-six. Tagged candidates can run outside the service-wide cap, so Terraform bounds instances × pool × 3 at 64 connections, leaving dedicated capacity for maintenance and operators. Increase pool sizes only after measuring contention. The shared Relay budget excludes push entirely. @@ -57,7 +57,7 @@ Set on the container by Terraform: | `ORCA_PUSH_PUBLIC_URL` | `push_base_url` | | `ORCA_PUSH_FCM_PROJECT_ID` | `project_id` (required for standalone runtime) | | `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-dedicated-database-url`, pinned version | -| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance | +| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 6 per instance | | `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` | | `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` | | `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` | @@ -180,7 +180,7 @@ runtime identity with a validate-only FCM request. Cloud Run rejects deletion of created revision even when it has no tag or traffic. Activation therefore creates a successor before removing the validation tag and deleting validation. The dedicated 64-connection budget reserves three simultaneous revision pools: serving, validation/rejected, -and active/recovery successor (12 configured pool connections at the current two-by-two shape). +and active/recovery successor (36 configured pool connections at the current two-by-six shape). Revision deletion is not proof of physical SQL session drain; verify termination and SQL sessions in controlled rollout acceptance. There is no shutdown sleep used as a drain gate. diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 0f6ce9deda0..e8f2f47485f 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -444,4 +444,5 @@ push_gateway_enabled = true push_base_url = "https://push.onorca.dev" # Dedicated push pools allow three revision resources during validation and recovery. push_max_instances = 2 +push_database_pool_max = 6 manage_push_domain_mapping = true diff --git a/cloud/package.json b/cloud/package.json index 0dd2a546699..c029b06d6fa 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -20,8 +20,8 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/push-validation-workflow.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 7032e635d93..0b15e696f46 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -227,6 +227,11 @@ module.exports = { // Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each). // CI never creates it, but packaging on a machine that has run dev would pack them all. '!out/electron-dev{,/**/*}', + // Why: relayExtraResource already ships out/relay to resources/relay, which is + // the only path a packaged build resolves. Packing it again added 14MB and put + // relay.js inside app.asar, so a script-heuristic verdict on relay.js took the + // whole asar with it as a compound object and gutted the install (#20966, #20972). + '!out/relay{,/**/*}', '!electron.vite.config.{js,ts,mjs,cjs}', '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index 4728c38a7e9..d951695a339 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -71,10 +71,9 @@ // // In the screenshot annotator a "shape" is the drawn geometry -- pen, arrow, rect, // ellipse, highlight. A domain noun, and it pervades every symbol in the module. - // mobile/src/test-support/rpc-recording is the golden recorder engine. recorder-digest.ts - // hashes these files' RAW BYTES into every golden's `recorderSha256` header, so any edit - // here -- a rename or even an added comment -- invalidates all 208 recordings. The exemption - // is config-scoped for that reason: an inline directive would change the bytes it protects. + // In mobile/src/test-support/rpc-recording a "shape" is the container a recorded value is + // interned as (list, map or whole) and the structure a recorder fixture must match -- a + // domain noun across the engine, as in the annotator. { "files": ["**/test-support/rpc-recording/**"], "rules": { @@ -113,7 +112,7 @@ // `shapedSidebar` is a persisted onboarding-checklist field and a telemetry enum member; // renaming it would orphan saved state. { - "files": ["**/src/shared/constants.ts", "**/src/shared/onboarding-state-types.ts"], + "files": ["**/src/shared/onboarding-defaults.ts", "**/src/shared/onboarding-state-types.ts"], "rules": { "anti-slop/no-shape-in-symbol-names": "off" } diff --git a/config/patches/@xterm__addon-image@0.10.0-beta.300.patch b/config/patches/@xterm__addon-image@0.10.0-beta.300.patch index c44cb33d2d5..44d6ac32c14 100644 --- a/config/patches/@xterm__addon-image@0.10.0-beta.300.patch +++ b/config/patches/@xterm__addon-image@0.10.0-beta.300.patch @@ -1,24 +1,24 @@ diff --git a/lib/addon-image.js b/lib/addon-image.js -index 38cf9e8708a5f314b8d587e5428ce7facb0d4076..4807d674d75a5aeaba40cf86972ce0a6158a33f4 100644 +index 38cf9e8708a5f314b8d587e5428ce7facb0d4076..4c5f44e00b0972978d762df5990de86002537649 100644 --- a/lib/addon-image.js +++ b/lib/addon-image.js @@ -1,3 +1,3 @@ /*! For license information please see addon-image.js.LICENSE.txt */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ImageAddon=t():e.ImageAddon=t()}(globalThis,()=>(()=>{"use strict";var e={939(e,t){function A(e){return 255&e}function i(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,A,i=255){return((255&i)<<24|(255&A)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,A){return Math.max(e,Math.min(A,t))}function a(e,t,A){return A<0&&(A+=1),A>1&&(A-=1),6*A<1?t+6*(e-t)*A:2*A<1?e:3*A<2?t+(e-t)*(4-6*A):t}function n(e,t,A){return(4278190080|Math.round(A/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=A,t.green=i,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=A(e),o=i(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let A=0;A<6;++A)for(let i=0;i<6;++i)for(let s=0;s<6;++s)e.push(r(t[A],t[i],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const i=A(939),s=A(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),A=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:i.DEFAULT_FOREGROUND,fillColor:i.DEFAULT_BACKGROUND,palette:i.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,A={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,A).then(A=>(o=o||A.module,new d(e,A.instance||A,t)))}t.DecoderAsync=g;class d{constructor(e,t,A){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)A.bandHandler=this._handle_band.bind(this),A.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(i.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const A=e+t;if(A>this._canvas.length){if(this._opts.memoryLimit&&4*A>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(A/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let A=this._lastOffset;if(2===this._mode){let i=this.height-this._currentHeight,s=0;for(;s<6&&i>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),A+e*s),s++,i--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(A,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let i=0;i<6;++i)this._canvas.set(this._pSrc.subarray(t*i,t*i+e),A+e*i);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,A=this._opts.paletteLimit,i=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,A,i?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,A=e.length){let i=t;for(;i0){const A=this._PIXEL_OFFSET;let i=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(A*s,A*s+e),i+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,i+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let A=this._lastOffset;this._realloc(A,6*e);for(let i=0;i<6;++i)this._canvas.set(this._pSrc.subarray(t*i,t*i+e),A+e*i)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let A=0,i=0;for(let e=0;e{if(this._disposed)return(0,i.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return A&&(Array.isArray(A)?A.push(r):A.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let A=0,i=t.length;At.fire(e))},e.map=function(e,t){return(A,i,s)=>e(e=>A.call(i,t(e)),void 0,s)},e.any=function(...e){return(t,A,s)=>{const r=new i.DisposableStore;for(const i of e)r.add(i(e=>t.call(A,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,A){return t(A),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function A(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=A,t.dispose=i,t.combinedDisposable=function(...e){return A(()=>i(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,A){var i=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=A(463),r=A(699),o=i(A(669)),a=i(A(61)),n=A(389),h=A(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,A,i){this._opts=e,this._renderer=t,this._storage=A,this._coreTerminal=i,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(4194304,s,r),this._qoiDec=new a.default(4194304)}reset(){this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,A){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,A))&&(this._dec.release(),this._aborted=!0);else{const i=this._hp.parse(e,t,A);if(-1===i)return void(this._aborted=!0);if(i>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);this._dec.init()}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(i,A))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,A,i;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,A=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,A=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(i=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==i?i:1,o=`]1337;ReportCellSize=${A.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*l(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_resize(e,t){var A,i,s,o;const a=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const A=h/e,i=(g-n)/t,s=Math.min(A,i);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,A){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*A}}},389(e,t){function A(e){let t="";for(let A=0;A57)throw new Error("illegal char");t=10*t+e[A]-48}return t}function s(e){const t=A(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:i,size:i,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(A(e),"base64").toString();const t=atob(A(e)),i=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==i||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=i,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=A(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const A=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===A[0]&&169478669===A[1]&&1380206665===A[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,A]=function(e){const t=e.length;let A=4,i=e[A]<<8|e[A+1];for(;;){if(A+=i,A>=t)return[0,0];if(255!==e[A])return[0,0];if(192===e[A+1]||194===e[A+1])return A+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===A[1]&&(1718187617===A[2]||1936291425===A[2])){let A=-1;const i=Math.min(e.length-16,1024);for(let t=8;t0&&i>0)return{mime:"image/avif",width:t,height:i}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const i=A(939),s=A(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,A){const i=(null!=e?e:document).createElement("canvas");return i.width=0|t,i.height=0|A,i}static createImageData(e,t,A,i){if("function"!=typeof ImageData){const s=e.createImageData(t,A);return i&&s.data.set(new Uint8ClampedArray(i,0,t*A*4)),s}return i?new ImageData(new Uint8ClampedArray(i,0,t*A*4),t,A):new ImageData(t,A)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,A;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(A=this._renderService)||void 0===A||A.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,A){var i,s,r,o,a;const n=e*((null===(i=this.dimensions)||void 0===i?void 0:i.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);A&&"top"!==A||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),A&&"bottom"!==A||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,A,i,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,h=Math.ceil(n.width/o),g=t%h*o,d=Math.floor(t/h)*a,l=A*o,I=i*a,c=s*o+g>n.width?n.width-g:s*o,_=d+a>n.height?n.height-d:a;r.drawImage(n,Math.floor(g),Math.floor(d),Math.ceil(c),Math.ceil(_),Math.floor(l),Math.floor(I),Math.ceil(c),Math.ceil(_))}extractTile(e,t){const{width:A,height:i}=this.cellSize;if(-1===A||-1===i)return;this._rescaleImage(e,A,i);const s=e.actual,o=Math.ceil(s.width/A),a=t%o*A,n=Math.floor(t/o)*i,h=A+a>s.width?s.width-a:A,g=n+i>s.height?s.height-n:i,d=r.createCanvas(this.document,h,g),l=d.getContext("2d");return l?(l.drawImage(s,Math.floor(a),Math.floor(n),Math.floor(h),Math.floor(g),0,0,Math.floor(h),Math.floor(g)),d):void 0}drawPlaceholder(e,t,A=1){var i;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(i=this._placeholderBitmap)&&void 0!==i?i:this._placeholder,e*r,t*o%2?0:1,r*A,o,e*r,t*o,r*A,o)}}rescaleCanvas(){var e,t;const A=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,i=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===A&&e.canvas.height===i||(e.canvas.width=A,e.canvas.height=i)}_rescaleImage(e,t,A){if(t===e.actualCellSize.width&&A===e.actualCellSize.height)return;const{width:i,height:s}=e.origCellSize;if(t===i&&A===s)return e.actual=e.orig,e.actualCellSize.width=i,void(e.actualCellSize.height=s);const o=r.createCanvas(this.document,Math.ceil(e.orig.width*t/i),Math.ceil(e.orig.height*A/s)),a=o.getContext("2d");a&&(a.drawImage(e.orig,0,0,o.width,o.height),e.actual=o,e.actualCellSize.width=t,e.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,A;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const i=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(A=this.dimensions)||void 0===A?void 0:A.css.canvas.height)||0);i.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(i.style.zIndex="-1",s.insertBefore(i,s.firstChild)):(i.style.zIndex="0",s.appendChild(i));const o=i.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):i.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const A=32,s=r.createCanvas(this.document,A,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,A,e),n=new Uint32Array(a.data.buffer),h=(0,i.toRGBA8888)(0,0,0),g=(0,i.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;tthis._placeholderBitmap=e)}else this._placeholder=void 0}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const i=A(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,A=-1,i=-1){this.imageId=A,this.tileId=i,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,A){this._terminal=e,this._renderer=t,this._opts=A,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const A=this._images.get(e);A&&(this._images.delete(e),window.ImageBitmap&&A.orig instanceof ImageBitmap&&A.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[A,i]of this._images.entries())"alternate"===i.bufferType&&(null===(e=i.marker)||void 0===e||e.dispose(),t.push(A));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const A=this._images.get(e);A&&(null===(t=A.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,A){var i,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;A.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++A)this._writeToCell(t,c+A,n,e*o+A),_++;if(A.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),A.scrolling?"iip"===A.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(i=t.marker)||void 0===i||i.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:A.layer,zIndex:A.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,A;let i=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:i=!0,i&&s)break;if(i&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!i&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const i=n.lines.get(e+n.ydisp);if(!i)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,A;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const i=this._terminal._core.buffer,s=i.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-i.tileId%h+o);let l=i.tileId;for(let e=o+1;e>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}o.set(i.PALETTE_VT340_COLOR),t.SixelHandler=class{constructor(e,t,A){this._opts=e,this._storage=t,this._coreTerminal=A,this._size=0,this._aborted=!1,(0,r.DecoderAsync)({memoryLimit:4*this._opts.pixelLimit,palette:o,paletteLimit:this._opts.sixelPaletteLimit}).then(e=>this._dec=e)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,o,this._opts.sixelPaletteLimit))}hook(e){var t;if(this._size=0,this._aborted=!1,this._dec){const A=1===e.params[1]?0:function(e,t){let A=0;if(!t)return A;if(e.isInverse())if(e.isFgDefault())A=a(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());A=(0,i.toRGBA8888)(...t)}else A=a(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())A=a(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());A=(0,i.toRGBA8888)(...t)}else A=a(t.ansi[e.getBgColor()].rgba);return A}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(A,null,this._opts.sixelPaletteLimit)}}put(e,t,A){if(!this._aborted&&this._dec){if(this._size+=A-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,A)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const A=this._dec.width,i=this._dec.height;if(!A||!i)return i&&this._storage.advanceCursor(i),!0;const r=s.ImageRenderer.createCanvas(void 0,A,i);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,A,i),0,0),this._dec.memoryUsage>4194304&&this._dec.release(),this._storage.addImage(r),!0}}},994(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const i=A(699);t.SixelImageStorage=class{constructor(e,t,A,i){this._storage=e,this._opts=t,this._renderer=A,this._terminal=i,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=i.CELL_SIZE_DEFAULT);const A=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,a.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(i=this._parsedCommand.quiet)&&void 0!==i?i:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(n=this._activeDecoder)&&void 0!==n?n:null==g?void 0:g.decoder;return e&&e.release(),this._activeDecoder=null,g&&this._removePendingEntry(h),void(this._aborted=!0)}this._decodeError||((null==g?void 0:g.decoder)&&!this._activeDecoder&&(this._activeDecoder=g.decoder),this._activeDecoder||(this._activeDecoder=new o.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()),0!==this._activeDecoder.put(e.subarray(t,A))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,g&&this._removePendingEntry(h)))}end(e){var t,A;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const i=this._parsedCommand;if("d"===i.action)return this._handleDelete(i);const s=null!==(A=null!==(t=i.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==A?A:0,r=1===i.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},i),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=i,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),i}case"T":return this._handleTransmitDisplay(e,t,A);case"q":return this._handleQuery(e,t,A);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const A=e.id,i=this._kittyStorage.getImage(A);return i?this._displayImage(i,e).then(t=>{var i;return this._sendResponse(A,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0,e.placementId),!0}):(this._sendResponse(A,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,A){var i,s,r,o,a,n;return"d"!==(null!==(i=e.transmission)&&void 0!==i?i:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(A||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,A){var i,s;if(A)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(i=e.quiet)&&void 0!==i?i:0),!0;this._handleTransmit(e,t,A);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var A;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,A){var i,s,r,o,a,n;const h=null!==(i=e.id)&&void 0!==i?i:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(A)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const A=null!==(a=e.width)&&void 0!==a?a:0,i=null!==(n=e.height)&&void 0!==n?n:0;if(!A||!i)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=A*i*(32===d?4:3);if(t.length=1)return;if(!s&&A>=2)return;const r=`_Gi=${e}${i?`,p=${i}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var A,i,o,a,n,h,g;let d=await this._createBitmap(e);try{const l=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),I=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),c=t.sourceWidth||d.width-l,_=t.sourceHeight||d.height-I,C=Math.max(0,d.width-l),B=Math.max(0,d.height-I),Q=Math.max(0,Math.min(c,C)),m=Math.max(0,Math.min(_,B));if(0===Q||0===m)throw new Error("invalid source rectangle");if(0!==l||0!==I||Q!==d.width||m!==d.height){const e=await createImageBitmap(d,l,I,Q,m);d.close(),d=e}const E=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.width)||r.CELL_SIZE_DEFAULT.width,u=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,p;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,p=t.rows):void 0!==t.columns?(w=t.columns,p=Math.max(1,Math.ceil(d.height/d.width*(w*E)/u))):void 0!==t.rows?(p=t.rows,w=Math.max(1,Math.ceil(d.width/d.height*(p*u)/E))):(w=Math.ceil(d.width/E),p=Math.ceil(d.height/u));let f=d.width,D=d.height;if(void 0===t.columns&&void 0===t.rows||(f=Math.round(w*E),D=Math.round(p*u)),f*D>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const y=this._coreTerminal._core.buffer,v=y.x,k=y.y,M=y.ybase,b=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(f!==d.width||D!==d.height){const e=await createImageBitmap(d,{resizeWidth:f,resizeHeight:D});d.close(),d=e}const S=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),E-1),L=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),u-1);if(0!==S||0!==L){const e=void 0!==t.columns?Math.round(w*E):d.width+S,A=void 0!==t.rows?Math.round(p*u):d.height+L,i=s.ImageRenderer.createCanvas(window.document,e,A),r=i.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(d,S,L);const o=await createImageBitmap(i);if(i.width=i.height=0,d.close(),d=o,f=d.width,D=d.height,f*D>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(d.width/E)),void 0===t.rows&&(p=Math.ceil(d.height/u))}const N=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,d,!0,b,N),d=void 0,1===t.cursorMovement){const e=y.ybase-M;y.x=v,y.y=Math.max(k-e,0)}else y.x=Math.min(v+w,this._coreTerminal.cols)}catch(e){throw null==d||d.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const t=URL.createObjectURL(e),A=new Image;return new Promise((e,i)=>{A.addEventListener("load",()=>{var r;URL.revokeObjectURL(t);const o=s.ImageRenderer.createCanvas(window.document,A.width,A.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(A,0,0),createImageBitmap(o).then(e).catch(i)}),A.addEventListener("error",()=>{URL.revokeObjectURL(t),i(new Error("Failed to load image"))}),A.src=t})}return createImageBitmap(e)}const A=e.width,i=e.height;if(!A||!i)throw new Error("Width and height required for raw pixel data");const r=A*i*(32===e.format?4:3);if(t.length>>24|t<<8,h[l++]=4278190080|t>>>16|A<<16,h[l++]=4278190080|A>>>8}let I=3*g,c=4*g;for(let e=g;ee+t.length,0),a=new Uint8Array(o);let n=0;for(const e of s)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},A=e.split(",");for(const e of A){const A=e.indexOf("=");if(-1===A)continue;const i=e.substring(0,A),s=e.substring(A+1);if("a"===i){t.action=s;continue}if("o"===i){t.compression=s;continue}if("t"===i){t.transmission=s;continue}if("d"===i){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(i){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class A{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const i=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(i);return void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(s)),!this._images.has(i)&&this._images.size>=A._maxStoredImages&&this._evictUndisplayedImages(),this._images.set(i,Object.assign(Object.assign({},t),{id:i})),i}addImage(e,t,A,i,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=A,this._addImageOpts.layer=i,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=A._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=A,A._maxStoredImages=256},669(e,t,A){Object.defineProperty(t,"__esModule",{value:!0});const i=(0,A(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let A=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,A){Object.defineProperty(t,"__esModule",{value:!0});const i=(0,A(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,A=4*t,s=e.length,r=Math.max(A,s)+(Math.min(A,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:i,d:s}=e;let r,o;const a=WebAssembly;return 0===t?i?e=>new a.Instance(o||(o=new a.Module(r||(r=A(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=A(s)),e).then(e=>(o=e.module)&&e.instance):1===t?i?()=>o||(o=new a.Module(r||(r=A(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=A(s))).then(e=>o=e):i?()=>r||(r=A(s)):()=>Promise.resolve(r||(r=A(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let A=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),A=new Uint8Array(t.length);for(let e=0;e{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=A(414),s=A(795),r=A(463),o=A(699),a=A(23),n=A(235),h=A(566),g=A(994),d=A(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const A=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};A.getWinSizePixels=!0,A.getCellSizePixels=!0,A.getWinSizeChars=!0,e.options.windowOptions=A}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),A=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",A),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},A))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),A=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",A),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,A))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),A=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",A),this._disposeLater(t,A,e._core._inputHandler._parser.registerApcHandler({final:"G"},A))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var A;return null===(A=this._storage)||void 0===A?void 0:A.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var A;return null===(A=this._storage)||void 0===A?void 0:A.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(A=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===A?void 0:A.css.canvas.width,h=null===(s=null===(i=this._renderer)||void 0===i?void 0:i.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h(()=>{"use strict";var e={939(e,t){function i(e){return 255&e}function A(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,i,A=255){return((255&A)<<24|(255&i)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,i){return Math.max(e,Math.min(i,t))}function a(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(4-6*i):t}function n(e,t,i){return(4278190080|Math.round(i/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=i,t.green=A,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=i(e),o=A(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let i=0;i<6;++i)for(let A=0;A<6;++A)for(let s=0;s<6;++s)e.push(r(t[i],t[A],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const A=i(939),s=i(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:A.DEFAULT_FOREGROUND,fillColor:A.DEFAULT_BACKGROUND,palette:A.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,i={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,i).then(i=>(o=o||i.module,new d(e,i.instance||i,t)))}t.DecoderAsync=g;class d{constructor(e,t,i){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(A.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&4*i>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(i/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let i=this._lastOffset;if(2===this._mode){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(i,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){const i=this._PIXEL_OFFSET;let A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let i=this._lastOffset;this._realloc(i,6*e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let e=0;e{if(this._disposed)return(0,A.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,A.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let i=0,A=t.length;it.fire(e))},e.map=function(e,t){return(i,A,s)=>e(e=>i.call(A,t(e)),void 0,s)},e.any=function(...e){return(t,i,s)=>{const r=new A.DisposableStore;for(const A of e)r.add(A(e=>t.call(i,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function i(e){return{dispose:e}}function A(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=A,t.combinedDisposable=function(...e){return i(()=>A(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,i){var A=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=i(463),r=i(699),o=A(i(669)),a=A(i(61)),n=i(389),h=i(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,i,A){this._opts=e,this._renderer=t,this._storage=i,this._coreTerminal=A,this._generation=0,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(4194304,s,r),this._qoiDec=new a.default(4194304)}reset(){this._generation++,this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,i){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,i))&&(this._dec.release(),this._aborted=!0);else{const A=this._hp.parse(e,t,i);if(-1===A)return void(this._aborted=!0);if(A>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);this._dec.init()}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(A,i))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,i,A;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,i=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,i=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(A=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==A?A:1,o=`]1337;ReportCellSize=${i.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*lc!==this._generation?(e.close(),!0):(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_resize(e,t){var i,A,s,o;const a=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const i=h/e,A=(g-n)/t,s=Math.min(i,A);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,i){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}}},389(e,t){function i(e){let t="";for(let i=0;i57)throw new Error("illegal char");t=10*t+e[i]-48}return t}function s(e){const t=i(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:A,size:A,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(i(e),"base64").toString();const t=atob(i(e)),A=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==A||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=i(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const i=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===i[0]&&169478669===i[1]&&1380206665===i[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,i]=function(e){const t=e.length;let i=4,A=e[i]<<8|e[i+1];for(;;){if(i+=A,i>=t)return[0,0];if(255!==e[i])return[0,0];if(192===e[i+1]||194===e[i+1])return i+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===i[1]&&(1718187617===i[2]||1936291425===i[2])){let i=-1;const A=Math.min(e.length-16,1024);for(let t=8;t0&&A>0)return{mime:"image/avif",width:t,height:A}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const A=i(939),s=i(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,i){const A=(null!=e?e:document).createElement("canvas");return A.width=0|t,A.height=0|i,A}static createImageData(e,t,i,A){if("function"!=typeof ImageData){const s=e.createImageData(t,i);return A&&s.data.set(new Uint8ClampedArray(A,0,t*i*4)),s}return A?new ImageData(new Uint8ClampedArray(A,0,t*i*4),t,i):new ImageData(t,i)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,i;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(i=this._renderService)||void 0===i||i.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,i){var A,s,r,o,a;const n=e*((null===(A=this.dimensions)||void 0===A?void 0:A.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);i&&"top"!==i||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),i&&"bottom"!==i||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,i,A,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,{width:h,height:g}=e.actualCellSize,d=Math.ceil(n.width/h),l=t%d*h,I=Math.floor(t/d)*g,c=i*o,_=A*a,C=s*h+l>n.width?n.width-l:s*h,B=I+g>n.height?n.height-I:g;r.drawImage(n,Math.floor(l),Math.floor(I),Math.ceil(C),Math.ceil(B),Math.floor(c),Math.floor(_),Math.ceil(C*o/h),Math.ceil(B*a/g))}extractTile(e,t){const{width:i,height:A}=this.cellSize;if(-1===i||-1===A)return;this._rescaleImage(e,i,A);const s=e.actual,{width:o,height:a}=e.actualCellSize,n=Math.ceil(s.width/o),h=t%n*o,g=Math.floor(t/n)*a,d=o+h>s.width?s.width-h:o,l=g+a>s.height?s.height-g:a,I=r.createCanvas(this.document,Math.ceil(d*i/o),Math.ceil(l*A/a)),c=I.getContext("2d");return c?(c.drawImage(s,Math.floor(h),Math.floor(g),Math.floor(d),Math.floor(l),0,0,I.width,I.height),I):void 0}drawPlaceholder(e,t,i=1){var A;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(A=this._placeholderBitmap)&&void 0!==A?A:this._placeholder,e*r,t*o%2?0:1,r*i,o,e*r,t*o,r*i,o)}}rescaleCanvas(){var e,t;const i=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,A=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===i&&e.canvas.height===A||(e.canvas.width=i,e.canvas.height=A)}_rescaleImage(e,t,i){if(t===e.actualCellSize.width&&i===e.actualCellSize.height)return;const{width:A,height:s}=e.origCellSize;if(t===A&&i===s)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const o=Math.ceil(e.orig.width*t/A),a=Math.ceil(e.orig.height*i/s);if(o*a>e.orig.width*e.orig.height)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const n=r.createCanvas(this.document,o,a),h=n.getContext("2d");h&&(h.drawImage(e.orig,0,0,n.width,n.height),e.actual=n,e.actualCellSize.width=t,e.actualCellSize.height=i)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,i;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const A=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(i=this.dimensions)||void 0===i?void 0:i.css.canvas.height)||0);A.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(A.style.zIndex="-1",s.insertBefore(A,s.firstChild)):(A.style.zIndex="0",s.appendChild(A));const o=A.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):A.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const i=32,s=r.createCanvas(this.document,i,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,i,e),n=new Uint32Array(a.data.buffer),h=(0,A.toRGBA8888)(0,0,0),g=(0,A.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;t{this._placeholder!==I?null==e||e.close():this._placeholderBitmap=e}).catch(()=>{})}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const A=i(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,i=-1,A=-1){this.imageId=i,this.tileId=A,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,i){this._terminal=e,this._renderer=t,this._opts=i,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const i=this._images.get(e);i&&(this._images.delete(e),window.ImageBitmap&&i.orig instanceof ImageBitmap&&i.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[i,A]of this._images.entries())"alternate"===A.bufferType&&(null===(e=A.marker)||void 0===e||e.dispose(),t.push(i));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const i=this._images.get(e);i&&(null===(t=i.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,i){var A,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;i.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++i)this._writeToCell(t,c+i,n,e*o+i),_++;if(i.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),i.scrolling?"iip"===i.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(A=t.marker)||void 0===A||A.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:i.layer,zIndex:i.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,i;let A=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:A=!0,A&&s)break;if(A&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!A&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const A=n.lines.get(e+n.ydisp);if(!A)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,i;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const A=this._terminal._core.buffer,s=A.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-A.tileId%h+o);let l=A.tileId;for(let e=o+1;e>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}o.set(A.PALETTE_VT340_COLOR),t.SixelHandler=class{constructor(e,t,i){this._opts=e,this._storage=t,this._coreTerminal=i,this._size=0,this._aborted=!1,(0,r.DecoderAsync)({memoryLimit:4*this._opts.pixelLimit,palette:o,paletteLimit:this._opts.sixelPaletteLimit}).then(e=>this._dec=e)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,o,this._opts.sixelPaletteLimit))}hook(e){var t;if(this._size=0,this._aborted=!1,this._dec){const i=1===e.params[1]?0:function(e,t){let i=0;if(!t)return i;if(e.isInverse())if(e.isFgDefault())i=a(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());i=(0,A.toRGBA8888)(...t)}else i=a(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())i=a(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());i=(0,A.toRGBA8888)(...t)}else i=a(t.ansi[e.getBgColor()].rgba);return i}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(i,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!this._aborted&&this._dec){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,i)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const i=this._dec.width,A=this._dec.height;if(!i||!A)return A&&this._storage.advanceCursor(A),!0;const r=s.ImageRenderer.createCanvas(void 0,i,A);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,i,A),0,0),this._dec.memoryUsage>4194304&&this._dec.release(),this._storage.addImage(r),!0}}},994(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const A=i(699);t.SixelImageStorage=class{constructor(e,t,i,A){this._storage=e,this._opts=t,this._renderer=i,this._terminal=A,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=A.CELL_SIZE_DEFAULT);const i=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,n.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(A=this._parsedCommand.quiet)&&void 0!==A?A:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(h=this._activeDecoder)&&void 0!==h?h:null==I?void 0:I.decoder;return e&&e.release(),this._activeDecoder=null,I&&this._removePendingEntry(l),void(this._aborted=!0)}if(!this._decodeError){if((null==I?void 0:I.decoder)&&!this._activeDecoder&&(this._activeDecoder=I.decoder),!this._activeDecoder){const e=this._maxEncodedBytes+131072;if(e>1e6*this._opts.storageLimit)return this._aborted=!0,void(void 0!==(null===(s=this._parsedCommand)||void 0===s?void 0:s.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",null!==(g=this._parsedCommand.quiet)&&void 0!==g?g:0));const t=Math.max(1,Math.floor(1e6*this._opts.storageLimit/e));for(;this._pendingTransmissions.size>=t;){const e=this._pendingTransmissions.entries().next().value;if(!e)break;e[1].decoder.release(),this._removePendingEntry(e[0]),void 0!==e[1].cmd.id&&this._sendResponse(e[1].cmd.id,"ENOMEM:pending image budget exceeded",null!==(d=e[1].cmd.quiet)&&void 0!==d?d:0)}this._activeDecoder=new a.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()}0!==this._activeDecoder.put(e.subarray(t,i))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,I&&this._removePendingEntry(l))}}end(e){var t,i;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const A=this._parsedCommand;if("d"===A.action)return this._handleDelete(A);const s=null!==(i=null!==(t=A.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==i?i:0,r=1===A.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},A),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=A,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),A}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const i=e.id,A=this._kittyStorage.getImage(i);return A?this._displayImage(A,e).then(t=>{var A;return this._sendResponse(i,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0,e.placementId),!0}):(this._sendResponse(i,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,i){var A,s,r,o,a,n;return"d"!==(null!==(A=e.transmission)&&void 0!==A?A:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(i||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,i){var A,s;if(i)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(A=e.quiet)&&void 0!==A?A:0),!0;this._handleTransmit(e,t,i);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var i;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,i){var A,s,r,o,a,n;const h=null!==(A=e.id)&&void 0!==A?A:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(i)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const i=null!==(a=e.width)&&void 0!==a?a:0,A=null!==(n=e.height)&&void 0!==n?n:0;if(!i||!A)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=i*A*(32===d?4:3);if(t.length=1)return;if(!s&&i>=2)return;const r=`_Gi=${e}${A?`,p=${A}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var i,A,o,a,n,h,g;const d=this._generation;let l=await this._createBitmap(e);try{if(d!==this._generation)throw new Error("image decode canceled");const I=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),c=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),_=t.sourceWidth||l.width-I,C=t.sourceHeight||l.height-c,B=Math.max(0,l.width-I),Q=Math.max(0,l.height-c),m=Math.max(0,Math.min(_,B)),E=Math.max(0,Math.min(C,Q));if(0===m||0===E)throw new Error("invalid source rectangle");if(0!==I||0!==c||m!==l.width||E!==l.height){const e=await createImageBitmap(l,I,c,m,E);l.close(),l=e}const u=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,p=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,f;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,f=t.rows):void 0!==t.columns?(w=t.columns,f=Math.max(1,Math.ceil(l.height/l.width*(w*u)/p))):void 0!==t.rows?(f=t.rows,w=Math.max(1,Math.ceil(l.width/l.height*(f*p)/u))):(w=Math.ceil(l.width/u),f=Math.ceil(l.height/p));let D=l.width,y=l.height;if(void 0===t.columns&&void 0===t.rows||(D=Math.round(w*u),y=Math.round(f*p)),D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const v=this._coreTerminal._core.buffer,k=v.x,M=v.y,b=v.ybase,S=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(D!==l.width||y!==l.height){const e=await createImageBitmap(l,{resizeWidth:D,resizeHeight:y});l.close(),l=e}const L=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),u-1),N=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),p-1);if(0!==L||0!==N){const e=void 0!==t.columns?Math.round(w*u):l.width+L,i=void 0!==t.rows?Math.round(f*p):l.height+N,A=s.ImageRenderer.createCanvas(window.document,e,i),r=A.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(l,L,N);const o=await createImageBitmap(A);if(A.width=A.height=0,l.close(),l=o,D=l.width,y=l.height,D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(l.width/u)),void 0===t.rows&&(f=Math.ceil(l.height/p))}if(d!==this._generation)throw new Error("image decode canceled");const G=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,l,!0,S,G),l=void 0,1===t.cursorMovement){const e=v.ybase-b;v.x=k,v.y=Math.max(M-e,0)}else v.x=Math.min(k+w,this._coreTerminal.cols)}catch(e){throw null==l||l.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=(0,o.imageType)(t);if("image/png"!==e.mime||!(e.width>0)||!(e.height>0)||e.width*e.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");const i=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const e=URL.createObjectURL(i),t=new Image;return new Promise((i,A)=>{t.addEventListener("load",()=>{var r;URL.revokeObjectURL(e);const o=s.ImageRenderer.createCanvas(window.document,t.width,t.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(t,0,0),createImageBitmap(o).then(i).catch(A)}),t.addEventListener("error",()=>{URL.revokeObjectURL(e),A(new Error("Failed to load image"))}),t.src=e})}return createImageBitmap(i)}const i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");const r=i*A*(32===e.format?4:3);if(t.length>>24|t<<8,g[I++]=4278190080|t>>>16|i<<16,g[I++]=4278190080|i>>>8}let c=3*d,_=4*d;for(let e=d;e=e.length)return void t.close();const i=Math.min(A+4096,e.length);t.enqueue(new Uint8Array(e.subarray(A,i))),A=i}}).pipeThrough(new DecompressionStream(t)).getReader(),r=[];let o=0;try{for(;;){const{done:e,value:t}=await s.read();if(e)break;if(o+=t.byteLength,o>i)throw await s.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");r.push(t)}}finally{s.releaseLock()}const a=new Uint8Array(o);let n=0;for(const e of r)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},i=e.split(",");for(const e of i){const i=e.indexOf("=");if(-1===i)continue;const A=e.substring(0,i),s=e.substring(i+1);if("a"===A){t.action=s;continue}if("o"===A){t.compression=s;continue}if("t"===A){t.transmission=s;continue}if("d"===A){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(A){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class i{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const A=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(A);void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(A),this._storageIdToKittyId.delete(s)),!this._images.has(A)&&this._images.size>=i._maxStoredImages&&this._evictUndisplayedImages();const r=1e6*this._storage.getLimit();this._images.delete(A);let o=0;for(const e of this._images.values())o+=e.data.size;for(const e of[!1,!0])for(const[i,A]of this._images){if(o+t.data.size<=r)break;this._kittyIdToStorageId.has(i)===e&&(o-=A.data.size,this.deleteById(i))}return this._images.set(A,Object.assign(Object.assign({},t),{id:A})),A}addImage(e,t,i,A,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=i._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=i,i._maxStoredImages=256},669(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,i=4*t,s=e.length,r=Math.max(i,s)+(Math.min(i,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:A,d:s}=e;let r,o;const a=WebAssembly;return 0===t?A?e=>new a.Instance(o||(o=new a.Module(r||(r=i(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=i(s)),e).then(e=>(o=e.module)&&e.instance):1===t?A?()=>o||(o=new a.Module(r||(r=i(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=i(s))).then(e=>o=e):A?()=>r||(r=i(s)):()=>Promise.resolve(r||(r=i(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let i=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e{var e=A;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=i(414),s=i(795),r=i(463),o=i(699),a=i(23),n=i(235),h=i(566),g=i(994),d=i(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._handlers.values())e.reset();for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const i=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};i.getWinSizePixels=!0,i.getCellSizePixels=!0,i.getWinSizeChars=!0,e.options.windowOptions=i}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),i=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),i=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),i=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(i=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===i?void 0:i.css.canvas.width,h=null===(s=null===(A=this._renderer)||void 0===A?void 0:A.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h(()=>{"use strict";var e={939(e,t){function i(e){return 255&e}function A(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,i,A=255){return((255&A)<<24|(255&i)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,i){return Math.max(e,Math.min(i,t))}function a(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(4-6*i):t}function n(e,t,i){return(4278190080|Math.round(i/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=i,t.green=A,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=i(e),o=A(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let i=0;i<6;++i)for(let A=0;A<6;++A)for(let s=0;s<6;++s)e.push(r(t[i],t[A],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const A=i(939),s=i(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:A.DEFAULT_FOREGROUND,fillColor:A.DEFAULT_BACKGROUND,palette:A.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,i={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,i).then(i=>(o=o||i.module,new d(e,i.instance||i,t)))}t.DecoderAsync=g;class d{constructor(e,t,i){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(A.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&4*i>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(i/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let i=this._lastOffset;if(2===this._mode){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(i,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){const i=this._PIXEL_OFFSET;let A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let i=this._lastOffset;this._realloc(i,6*e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let e=0;e{if(this._disposed)return(0,A.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,A.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let i=0,A=t.length;it.fire(e))},e.map=function(e,t){return(i,A,s)=>e(e=>i.call(A,t(e)),void 0,s)},e.any=function(...e){return(t,i,s)=>{const r=new A.DisposableStore;for(const A of e)r.add(A(e=>t.call(i,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function i(e){return{dispose:e}}function A(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=A,t.combinedDisposable=function(...e){return i(()=>A(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,i){var A=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=i(463),r=i(699),o=A(i(669)),a=A(i(61)),n=i(389),h=i(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,i,A){this._opts=e,this._renderer=t,this._storage=i,this._coreTerminal=A,this._generation=0,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(0,s,r),this._qoiDec=new a.default(0)}reset(){this._generation++,this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,i){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,i))&&(this._dec.release(),this._aborted=!0);else{const A=this._hp.parse(e,t,i);if(-1===A)return void(this._aborted=!0);if(A>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);if(!this._initDecoder())return void(this._aborted=!0)}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(A,i))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,i,A;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,i=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,i=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(A=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==A?A:1,o=`]1337;ReportCellSize=${i.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._initDecoder()||(this._abortMulti=!0),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*lc!==this._generation?(e.close(),!0):(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_initDecoder(){try{return this._dec.init(),!0}catch(e){return console.warn("IIP: could not allocate decoder",e),this._dec.release(),!1}}_resize(e,t){var i,A,s,o;const a=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const i=h/e,A=(g-n)/t,s=Math.min(i,A);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,i){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}}},389(e,t){function i(e){let t="";for(let i=0;i57)throw new Error("illegal char");t=10*t+e[i]-48}return t}function s(e){const t=i(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:A,size:A,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(i(e),"base64").toString();const t=atob(i(e)),A=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==A||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=i(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const i=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===i[0]&&169478669===i[1]&&1380206665===i[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,i]=function(e){const t=e.length;let i=4,A=e[i]<<8|e[i+1];for(;;){if(i+=A,i>=t)return[0,0];if(255!==e[i])return[0,0];if(192===e[i+1]||194===e[i+1])return i+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===i[1]&&(1718187617===i[2]||1936291425===i[2])){let i=-1;const A=Math.min(e.length-16,1024);for(let t=8;t0&&A>0)return{mime:"image/avif",width:t,height:A}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const A=i(939),s=i(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,i){const A=(null!=e?e:document).createElement("canvas");return A.width=0|t,A.height=0|i,A}static createImageData(e,t,i,A){if("function"!=typeof ImageData){const s=e.createImageData(t,i);return A&&s.data.set(new Uint8ClampedArray(A,0,t*i*4)),s}return A?new ImageData(new Uint8ClampedArray(A,0,t*i*4),t,i):new ImageData(t,i)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,i;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(i=this._renderService)||void 0===i||i.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,i){var A,s,r,o,a;const n=e*((null===(A=this.dimensions)||void 0===A?void 0:A.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);i&&"top"!==i||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),i&&"bottom"!==i||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,i,A,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,{width:h,height:g}=e.actualCellSize,d=Math.ceil(n.width/h),l=t%d*h,I=Math.floor(t/d)*g,c=i*o,_=A*a,C=s*h+l>n.width?n.width-l:s*h,B=I+g>n.height?n.height-I:g;r.drawImage(n,Math.floor(l),Math.floor(I),Math.ceil(C),Math.ceil(B),Math.floor(c),Math.floor(_),Math.ceil(C*o/h),Math.ceil(B*a/g))}extractTile(e,t){const{width:i,height:A}=this.cellSize;if(-1===i||-1===A)return;this._rescaleImage(e,i,A);const s=e.actual,{width:o,height:a}=e.actualCellSize,n=Math.ceil(s.width/o),h=t%n*o,g=Math.floor(t/n)*a,d=o+h>s.width?s.width-h:o,l=g+a>s.height?s.height-g:a,I=r.createCanvas(this.document,Math.ceil(d*i/o),Math.ceil(l*A/a)),c=I.getContext("2d");return c?(c.drawImage(s,Math.floor(h),Math.floor(g),Math.floor(d),Math.floor(l),0,0,I.width,I.height),I):void 0}drawPlaceholder(e,t,i=1){var A;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(A=this._placeholderBitmap)&&void 0!==A?A:this._placeholder,e*r,t*o%2?0:1,r*i,o,e*r,t*o,r*i,o)}}rescaleCanvas(){var e,t;const i=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,A=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===i&&e.canvas.height===A||(e.canvas.width=i,e.canvas.height=A)}_rescaleImage(e,t,i){if(t===e.actualCellSize.width&&i===e.actualCellSize.height)return;const{width:A,height:s}=e.origCellSize;if(t===A&&i===s)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const o=Math.ceil(e.orig.width*t/A),a=Math.ceil(e.orig.height*i/s);if(o*a>e.orig.width*e.orig.height)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const n=r.createCanvas(this.document,o,a),h=n.getContext("2d");h&&(h.drawImage(e.orig,0,0,n.width,n.height),e.actual=n,e.actualCellSize.width=t,e.actualCellSize.height=i)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,i;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const A=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(i=this.dimensions)||void 0===i?void 0:i.css.canvas.height)||0);A.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(A.style.zIndex="-1",s.insertBefore(A,s.firstChild)):(A.style.zIndex="0",s.appendChild(A));const o=A.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):A.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const i=32,s=r.createCanvas(this.document,i,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,i,e),n=new Uint32Array(a.data.buffer),h=(0,A.toRGBA8888)(0,0,0),g=(0,A.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;t{this._placeholder!==I?null==e||e.close():this._placeholderBitmap=e}).catch(()=>{})}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const A=i(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,i=-1,A=-1){this.imageId=i,this.tileId=A,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,i){this._terminal=e,this._renderer=t,this._opts=i,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const i=this._images.get(e);i&&(this._images.delete(e),window.ImageBitmap&&i.orig instanceof ImageBitmap&&i.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[i,A]of this._images.entries())"alternate"===A.bufferType&&(null===(e=A.marker)||void 0===e||e.dispose(),t.push(i));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const i=this._images.get(e);i&&(null===(t=i.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,i){var A,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;i.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++i)this._writeToCell(t,c+i,n,e*o+i),_++;if(i.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),i.scrolling?"iip"===i.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(A=t.marker)||void 0===A||A.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:i.layer,zIndex:i.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,i;let A=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:A=!0,A&&s)break;if(A&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!A&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const A=n.lines.get(e+n.ydisp);if(!A)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,i;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const A=this._terminal._core.buffer,s=A.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-A.tileId%h+o);let l=A.tileId;for(let e=o+1;e4194304&&e.release();const A=null!==(i=n.get(t))&&void 0!==i?i:[];A.length<2&&(A.push(e),n.set(t,A))}function d(e){return A.BIG_ENDIAN?e:(255&e)<<24|(e>>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}t.SixelHandler=class{constructor(e,t,i){var A;this._opts=e,this._storage=t,this._coreTerminal=i,this._size=0,this._aborted=!1,this._decMemoryLimit=0,this._palette=new Uint32Array(o.LIMITS.PALETTE_SIZE),this._palette.set(a),A=4*this._opts.pixelLimit,h||(h=!0,(0,r.DecoderAsync)({memoryLimit:A,palette:a}).then(e=>g(e,A),()=>{h=!1}))}reset(){this._returnDecoder(),this._palette.fill(0),this._palette.set(a)}hook(e){var t;this._size=0,this._aborted=!1,this._returnDecoder();const i=4*this._opts.pixelLimit;try{this._dec=function(e){var t,i;return null!==(i=null===(t=n.get(e))||void 0===t?void 0:t.pop())&&void 0!==i?i:new r.Decoder({memoryLimit:e,palette:a})}(i)}catch(e){return console.warn(`SIXEL: could not allocate decoder - ${e}`),void(this._aborted=!0)}this._decMemoryLimit=i;const s=1===e.params[1]?0:function(e,t){let i=0;if(!t)return i;if(e.isInverse())if(e.isFgDefault())i=d(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());i=(0,A.toRGBA8888)(...t)}else i=d(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())i=d(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());i=(0,A.toRGBA8888)(...t)}else i=d(t.ansi[e.getBgColor()].rgba);return i}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(s,this._palette,this._opts.sixelPaletteLimit)}_returnDecoder(){const e=this._dec;e&&(this._dec=void 0,this._palette.set(e.palette),g(e,this._decMemoryLimit))}put(e,t,i){if(!this._aborted&&this._dec){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,i)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){try{return this._unhook(e)}finally{this._returnDecoder()}}_unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const i=this._dec.width,A=this._dec.height;if(!i||!A)return A&&this._storage.advanceCursor(A),!0;const r=s.ImageRenderer.createCanvas(void 0,i,A);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,i,A),0,0),this._storage.addImage(r),!0}}},994(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const A=i(699);t.SixelImageStorage=class{constructor(e,t,i,A){this._storage=e,this._opts=t,this._renderer=i,this._terminal=A,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=A.CELL_SIZE_DEFAULT);const i=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,n.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(A=this._parsedCommand.quiet)&&void 0!==A?A:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(g=this._activeDecoder)&&void 0!==g?g:null==_?void 0:_.decoder;return e&&e.release(),this._activeDecoder=null,_&&this._removePendingEntry(c),void(this._aborted=!0)}if(!this._decodeError){if((null==_?void 0:_.decoder)&&!this._activeDecoder&&(this._activeDecoder=_.decoder),!this._activeDecoder){const e=this._maxEncodedBytes+131072;if(e>1e6*this._opts.storageLimit)return this._aborted=!0,void(void 0!==(null===(s=this._parsedCommand)||void 0===s?void 0:s.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",null!==(d=this._parsedCommand.quiet)&&void 0!==d?d:0));const t=Math.max(1,Math.floor(1e6*this._opts.storageLimit/e));for(;this._pendingTransmissions.size>=t;){const e=this._pendingTransmissions.entries().next().value;if(!e)break;e[1].decoder.release(),this._removePendingEntry(e[0]),void 0!==e[1].cmd.id&&this._sendResponse(e[1].cmd.id,"ENOMEM:pending image budget exceeded",null!==(l=e[1].cmd.quiet)&&void 0!==l?l:0)}const i=new a.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes);try{i.init()}catch(e){return console.warn("KITTY: could not allocate decoder",e),this._aborted=!0,void(void 0!==(null===(r=this._parsedCommand)||void 0===r?void 0:r.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:could not allocate decoder",null!==(I=this._parsedCommand.quiet)&&void 0!==I?I:0))}this._activeDecoder=i}0!==this._activeDecoder.put(e.subarray(t,i))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,_&&this._removePendingEntry(c))}}end(e){var t,i;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const A=this._parsedCommand;if("d"===A.action)return this._handleDelete(A);const s=null!==(i=null!==(t=A.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==i?i:0,r=1===A.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},A),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=A,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),A}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const i=e.id,A=this._kittyStorage.getImage(i);return A?this._displayImage(A,e).then(t=>{var A;return this._sendResponse(i,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0,e.placementId),!0}):(this._sendResponse(i,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,i){var A,s,r,o,a,n;return"d"!==(null!==(A=e.transmission)&&void 0!==A?A:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(i||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,i){var A,s;if(i)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(A=e.quiet)&&void 0!==A?A:0),!0;this._handleTransmit(e,t,i);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var i;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,i){var A,s,r,o,a,n;const h=null!==(A=e.id)&&void 0!==A?A:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(i)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const i=null!==(a=e.width)&&void 0!==a?a:0,A=null!==(n=e.height)&&void 0!==n?n:0;if(!i||!A)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=i*A*(32===d?4:3);if(t.length=1)return;if(!s&&i>=2)return;const r=`_Gi=${e}${A?`,p=${A}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var i,A,o,a,n,h,g;const d=this._generation;let l=await this._createBitmap(e);try{if(d!==this._generation)throw new Error("image decode canceled");const I=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),c=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),_=t.sourceWidth||l.width-I,C=t.sourceHeight||l.height-c,B=Math.max(0,l.width-I),Q=Math.max(0,l.height-c),m=Math.max(0,Math.min(_,B)),u=Math.max(0,Math.min(C,Q));if(0===m||0===u)throw new Error("invalid source rectangle");if(0!==I||0!==c||m!==l.width||u!==l.height){const e=await createImageBitmap(l,I,c,m,u);l.close(),l=e}const E=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,p=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,f;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,f=t.rows):void 0!==t.columns?(w=t.columns,f=Math.max(1,Math.ceil(l.height/l.width*(w*E)/p))):void 0!==t.rows?(f=t.rows,w=Math.max(1,Math.ceil(l.width/l.height*(f*p)/E))):(w=Math.ceil(l.width/E),f=Math.ceil(l.height/p));let D=l.width,y=l.height;if(void 0===t.columns&&void 0===t.rows||(D=Math.round(w*E),y=Math.round(f*p)),D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const v=this._coreTerminal._core.buffer,M=v.x,k=v.y,b=v.ybase,S=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(D!==l.width||y!==l.height){const e=await createImageBitmap(l,{resizeWidth:D,resizeHeight:y});l.close(),l=e}const L=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),E-1),N=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),p-1);if(0!==L||0!==N){const e=void 0!==t.columns?Math.round(w*E):l.width+L,i=void 0!==t.rows?Math.round(f*p):l.height+N,A=s.ImageRenderer.createCanvas(window.document,e,i),r=A.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(l,L,N);const o=await createImageBitmap(A);if(A.width=A.height=0,l.close(),l=o,D=l.width,y=l.height,D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(l.width/E)),void 0===t.rows&&(f=Math.ceil(l.height/p))}if(d!==this._generation)throw new Error("image decode canceled");const G=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,l,!0,S,G),l=void 0,1===t.cursorMovement){const e=v.ybase-b;v.x=M,v.y=Math.max(k-e,0)}else v.x=Math.min(M+w,this._coreTerminal.cols)}catch(e){throw null==l||l.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=(0,o.imageType)(t);if("image/png"!==e.mime||!(e.width>0)||!(e.height>0)||e.width*e.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");const i=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const e=URL.createObjectURL(i),t=new Image;return new Promise((i,A)=>{t.addEventListener("load",()=>{var r;URL.revokeObjectURL(e);const o=s.ImageRenderer.createCanvas(window.document,t.width,t.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(t,0,0),createImageBitmap(o).then(i).catch(A)}),t.addEventListener("error",()=>{URL.revokeObjectURL(e),A(new Error("Failed to load image"))}),t.src=e})}return createImageBitmap(i)}const i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");const r=i*A*(32===e.format?4:3);if(t.length>>24|t<<8,g[I++]=4278190080|t>>>16|i<<16,g[I++]=4278190080|i>>>8}let c=3*d,_=4*d;for(let e=d;e=e.length)return void t.close();const i=Math.min(A+4096,e.length);t.enqueue(new Uint8Array(e.subarray(A,i))),A=i}}).pipeThrough(new DecompressionStream(t)).getReader(),r=[];let o=0;try{for(;;){const{done:e,value:t}=await s.read();if(e)break;if(o+=t.byteLength,o>i)throw await s.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");r.push(t)}}finally{s.releaseLock()}const a=new Uint8Array(o);let n=0;for(const e of r)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},i=e.split(",");for(const e of i){const i=e.indexOf("=");if(-1===i)continue;const A=e.substring(0,i),s=e.substring(i+1);if("a"===A){t.action=s;continue}if("o"===A){t.compression=s;continue}if("t"===A){t.transmission=s;continue}if("d"===A){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(A){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class i{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const A=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(A);void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(A),this._storageIdToKittyId.delete(s)),!this._images.has(A)&&this._images.size>=i._maxStoredImages&&this._evictUndisplayedImages();const r=1e6*this._storage.getLimit();this._images.delete(A);let o=0;for(const e of this._images.values())o+=e.data.size;for(const e of[!1,!0])for(const[i,A]of this._images){if(o+t.data.size<=r)break;this._kittyIdToStorageId.has(i)===e&&(o-=A.data.size,this.deleteById(i))}return this._images.set(A,Object.assign(Object.assign({},t),{id:A})),A}addImage(e,t,i,A,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=i._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=i,i._maxStoredImages=256},669(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,i=4*t,s=e.length,r=Math.max(i,s)+(Math.min(i,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:A,d:s}=e;let r,o;const a=WebAssembly;return 0===t?A?e=>new a.Instance(o||(o=new a.Module(r||(r=i(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=i(s)),e).then(e=>(o=e.module)&&e.instance):1===t?A?()=>o||(o=new a.Module(r||(r=i(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=i(s))).then(e=>o=e):A?()=>r||(r=i(s)):()=>Promise.resolve(r||(r=i(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let i=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e{var e=A;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=i(414),s=i(795),r=i(463),o=i(699),a=i(23),n=i(235),h=i(566),g=i(994),d=i(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._handlers.values())e.reset();for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const i=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};i.getWinSizePixels=!0,i.getCellSizePixels=!0,i.getWinSizeChars=!0,e.options.windowOptions=i}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),i=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),i=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),i=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(i=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===i?void 0:i.css.canvas.width,h=null===(s=null===(A=this._renderer)||void 0===A?void 0:A.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: '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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const finalWidth = width + sx > img.width ? img.width - sx : width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document,\n Math.ceil(spec.orig!.width * currentWidth / originalWidth),\n Math.ceil(spec.orig!.height * currentHeight / originalHeight)\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch {\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const ds = new DecompressionStream(format);\n const writer = ds.writable.getWriter();\n writer.write(compressed as BufferSource);\n writer.close();\n\n const chunks: Uint8Array[] = [];\n const reader = ds.readable.getReader();\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","createImageBitmap","resizeWidth","resizeHeight","bm","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","close","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","actualCellSize","originalWidth","originalHeight","origCellSize","orig","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","convertLe","_size","sixelPaletteLimit","hook","params","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelSizeLimit","unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","totalEncodedSize","decoderToRelease","_handleNoPayloadCommand","cmd","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","_f","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","sourceWidth","cropH","sourceHeight","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","_g","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","ds","DecompressionStream","writer","writable","getWriter","write","chunks","reader","readable","getReader","done","read","totalLength","reduce","sum","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} \ No newline at end of file -+{"version":3,"file":"addon-image.js","mappings":"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","sources":["webpack://ImageAddon/webpack/universalModuleDefinition","webpack://ImageAddon/../node_modules/sixel/lib/Colors.js","webpack://ImageAddon/../node_modules/sixel/lib/Decoder.js","webpack://ImageAddon/../node_modules/sixel/lib/wasm.js","webpack://ImageAddon/../src/common/Event.ts","webpack://ImageAddon/../src/common/Lifecycle.ts","webpack://ImageAddon/./src/IIPHandler.ts","webpack://ImageAddon/./src/IIPHeaderParser.ts","webpack://ImageAddon/./src/IIPImageStorage.ts","webpack://ImageAddon/./src/IIPMetrics.ts","webpack://ImageAddon/./src/ImageRenderer.ts","webpack://ImageAddon/./src/ImageStorage.ts","webpack://ImageAddon/./src/SixelHandler.ts","webpack://ImageAddon/./src/SixelImageStorage.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsHandler.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsTypes.ts","webpack://ImageAddon/./src/kitty/KittyImageStorage.ts","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/base64/Base64Decoder.wasm.js","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/qoi/QoiDecoder.wasm.js","webpack://ImageAddon/javascript/node_modules/inwasm-runtime/lib/index.cjs","webpack://ImageAddon/webpack/bootstrap","webpack://ImageAddon/./src/ImageAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ImageAddon\"] = factory();\n\telse\n\t\troot[\"ImageAddon\"] = factory();\n})(globalThis, () => {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: '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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_generation","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","generation","createImageBitmap","resizeWidth","resizeHeight","bm","close","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sourceWidth","sourceHeight","actualCellSize","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","originalWidth","originalHeight","origCellSize","orig","scaledWidth","scaledHeight","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","placeholder","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","convertLe","_size","sixelPaletteLimit","hook","params","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelSizeLimit","unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","totalEncodedSize","decoderToRelease","_f","decoderCapacity","_g","maxPending","oldest","next","cmd","_h","_handleNoPayloadCommand","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","cropH","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","offsetIn","reader","ReadableStream","pull","controller","enqueue","pipeThrough","DecompressionStream","getReader","chunks","totalLength","done","read","cancel","releaseLock","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","byteLimit","retainedBytes","evictPlaced","oldestId","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} ++{"version":3,"file":"addon-image.js","mappings":"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","sources":["webpack://ImageAddon/webpack/universalModuleDefinition","webpack://ImageAddon/../node_modules/sixel/lib/Colors.js","webpack://ImageAddon/../node_modules/sixel/lib/Decoder.js","webpack://ImageAddon/../node_modules/sixel/lib/wasm.js","webpack://ImageAddon/../src/common/Event.ts","webpack://ImageAddon/../src/common/Lifecycle.ts","webpack://ImageAddon/./src/IIPHandler.ts","webpack://ImageAddon/./src/IIPHeaderParser.ts","webpack://ImageAddon/./src/IIPImageStorage.ts","webpack://ImageAddon/./src/IIPMetrics.ts","webpack://ImageAddon/./src/ImageRenderer.ts","webpack://ImageAddon/./src/ImageStorage.ts","webpack://ImageAddon/./src/SixelHandler.ts","webpack://ImageAddon/./src/SixelImageStorage.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsHandler.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsTypes.ts","webpack://ImageAddon/./src/kitty/KittyImageStorage.ts","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/base64/Base64Decoder.wasm.js","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/qoi/QoiDecoder.wasm.js","webpack://ImageAddon/javascript/node_modules/inwasm-runtime/lib/index.cjs","webpack://ImageAddon/webpack/bootstrap","webpack://ImageAddon/./src/ImageAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ImageAddon\"] = factory();\n\telse\n\t\troot[\"ImageAddon\"] = factory();\n})(globalThis, () => {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: '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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Held memory in base64/QOI decoders between images. Zero because each kept\n // decoder pins a wasm memory, and V8 caps those per process (~124 in a\n // sandboxed renderer), so idle terminals must not hold one.\n KEEP_DATA = 0,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n if (!this._initDecoder()) {\n this._aborted = true;\n return;\n }\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n if (!this._initDecoder()) {\n this._abortMulti = true;\n }\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n let data: Uint8Array;\n try {\n data = this._qoiDec.decode(this._dec.data8);\n } catch (e) {\n console.warn('IIP: could not decode QOI image', e);\n this._dec.release();\n this._qoiDec.release();\n return true;\n }\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n private _initDecoder(): boolean {\n try {\n this._dec.init();\n return true;\n } catch (e) {\n console.warn('IIP: could not allocate decoder', e);\n this._dec.release();\n return false;\n }\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\nimport { LIMITS } from 'sixel/lib/wasm';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories\n// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only\n// while a SIXEL sequence is open, so idle terminals hold none.\nconst MAX_IDLE_DECODERS = 2;\nconst idleDecoders = new Map();\nlet poolPrimed = false;\n\nfunction primeDecoderPool(memoryLimit: number): void {\n if (poolPrimed) return;\n poolPrimed = true;\n // Async compile once, off the parser's hot path; later decoders reuse the cached module.\n DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(\n d => releaseDecoder(d, memoryLimit),\n () => { poolPrimed = false; }\n );\n}\n\nfunction acquireDecoder(memoryLimit: number): Decoder {\n return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });\n}\n\nfunction releaseDecoder(dec: Decoder, memoryLimit: number): void {\n if (dec.memoryUsage > MEM_PERMA_LIMIT) {\n dec.release();\n }\n const idle = idleDecoders.get(memoryLimit) ?? [];\n if (idle.length < MAX_IDLE_DECODERS) {\n idle.push(dec);\n idleDecoders.set(memoryLimit, idle);\n }\n}\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n private _decMemoryLimit = 0;\n // Color registers outlive a single image, so they live here rather than in a pooled decoder.\n private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n this._palette.set(DEFAULT_PALETTE);\n primeDecoderPool(this._opts.pixelLimit * 4);\n }\n\n public reset(): void {\n this._returnDecoder();\n this._palette.fill(0);\n this._palette.set(DEFAULT_PALETTE);\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n this._returnDecoder();\n const memoryLimit = this._opts.pixelLimit * 4;\n try {\n this._dec = acquireDecoder(memoryLimit);\n } catch (e) {\n // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.\n console.warn(`SIXEL: could not allocate decoder - ${e}`);\n this._aborted = true;\n return;\n }\n this._decMemoryLimit = memoryLimit;\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);\n }\n\n private _returnDecoder(): void {\n const dec = this._dec;\n if (!dec) return;\n this._dec = undefined;\n this._palette.set(dec.palette);\n releaseDecoder(dec, this._decMemoryLimit);\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n try {\n return this._unhook(success);\n } finally {\n this._returnDecoder();\n }\n }\n\n private _unhook(success: boolean): boolean {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n try {\n decoder.init();\n } catch (e) {\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n console.warn('KITTY: could not allocate decoder', e);\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n this._activeDecoder = decoder;\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_generation","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","_initDecoder","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","generation","createImageBitmap","resizeWidth","resizeHeight","bm","close","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sourceWidth","sourceHeight","actualCellSize","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","originalWidth","originalHeight","origCellSize","orig","scaledWidth","scaledHeight","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","placeholder","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","idleDecoders","poolPrimed","releaseDecoder","idle","convertLe","_size","_decMemoryLimit","_returnDecoder","hook","params","pop","acquireDecoder","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelPaletteLimit","sixelSizeLimit","unhook","_unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","_f","totalEncodedSize","decoderToRelease","_g","decoderCapacity","_h","maxPending","oldest","next","cmd","_j","_k","_handleNoPayloadCommand","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","cropH","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","offsetIn","reader","ReadableStream","pull","controller","enqueue","pipeThrough","DecompressionStream","getReader","chunks","totalLength","done","read","cancel","releaseLock","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","byteLimit","retainedBytes","evictPlaced","oldestId","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} \ No newline at end of file diff --git a/lib/addon-image.mjs b/lib/addon-image.mjs -index 753a4f833c038a3f45802377dcb16a93695f2161..29c3984db57b888a058165c8aa02ccbcf8efcd2f 100644 +index 753a4f833c038a3f45802377dcb16a93695f2161..aca74ad0c769e8525bc5e4ab8cc6dda03959f760 100644 --- a/lib/addon-image.mjs +++ b/lib/addon-image.mjs @@ -14,5 +14,5 @@ @@ -26,10 +26,10 @@ index 753a4f833c038a3f45802377dcb16a93695f2161..29c3984db57b888a058165c8aa02ccbc * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -var it=Object.create;var Te=Object.defineProperty;var At=Object.getOwnPropertyDescriptor;var rt=Object.getOwnPropertyNames;var st=Object.getPrototypeOf,nt=Object.prototype.hasOwnProperty;var x=(r,e)=>()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var at=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of rt(e))!nt.call(r,A)&&A!==t&&Te(r,A,{get:()=>e[A],enumerable:!(i=At(e,A))||i.enumerable});return r};var M=(r,e,t)=>(t=r!=null?it(st(r)):{},at(e||!r||!r.__esModule?Te(t,"default",{value:r,enumerable:!0}):t,r));var X=x(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function xe(r){return r&255}_.red=xe;function Me(r){return r>>>8&255}_.green=Me;function ve(r){return r>>>16&255}_.blue=ve;function ht(r){return r>>>24&255}_.alpha=ht;function B(r,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|r&255)>>>0}_.toRGBA8888=B;function gt(r){return[r&255,r>>8&255,r>>16&255,r>>>24]}_.fromRGBA8888=gt;function It(r,e){let t=xe(r),i=Me(r),A=ve(r),s=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(r-e)*6*t:t*2<1?r:t*3<2?e+(r-e)*(4-t*6):e}function dt(r,e,t){if(!t){let s=Math.round(e*255);return B(s,s,s)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return B(ge(0,255,Math.round(Ie(i,A,r+1/3)*255)),ge(0,255,Math.round(Ie(i,A,r)*255)),ge(0,255,Math.round(Ie(i,A,r-1/3)*255)))}function m(r,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(r/100*255))>>>0}_.normalizeRGB=m;function lt(r,e,t){return dt((r+240%360)/360,e/100,t/100)}_.normalizeHLS=lt;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let r=[B(0,0,0),B(205,0,0),B(0,205,0),B(205,205,0),B(0,0,238),B(205,0,205),B(0,250,205),B(229,229,229),B(127,127,127),B(255,0,0),B(0,255,0),B(255,255,0),B(92,92,255),B(255,0,255),B(0,255,255),B(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)r.push(B(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)r.push(B(t,t,t));return new Uint32Array(r)})();_.DEFAULT_BACKGROUND=B(0,0,0,255);_.DEFAULT_FOREGROUND=B(255,255,255,255)});var ce=x(le=>{"use strict";Object.defineProperty(le,"__esModule",{value:!0});le.InWasm=Ct;var v=r=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(r);if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(s||(s=new n.Module(A||(A=v(i)))),a):a=>s?n.instantiate(s,a):n.instantiate(A||(A=v(i)),a).then(o=>(s=o.module)&&o.instance):e===1?t?()=>s||(s=new n.Module(A||(A=v(i)))):()=>s?Promise.resolve(s):n.compile(A||(A=v(i))).then(a=>s=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(r)}});var ue=x(_e=>{"use strict";Object.defineProperty(_e,"__esModule",{value:!0});var pt=ce(),Bt=(0,pt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(r=>r.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let r=0;r>4|(r<<4&255)<<8;for(let r=0;r>2<<8|(r<<6&255)<<16;for(let r=0;rthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):ft}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};_e.default=me});var Re=x(Ce=>{"use strict";Object.defineProperty(Ce,"__esModule",{value:!0});var Qt=ce(),Dt=(0,Qt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),Ee=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,s=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Ce.default=Ee});var Ke=x(ie=>{"use strict";Object.defineProperty(ie,"__esModule",{value:!0});ie.LIMITS=void 0;ie.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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"}});var We=x(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=X(),f=Ke();function yt(r){if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},St={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:f.LIMITS.PALETTE_SIZE,truncate:!0};function qe(r){let e=new ye,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||Ye,t).then(i=>(J=J||i.module,new P(r,i.instance||i,e)))}b.DecoderAsync=qe;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=f.LIMITS.MAX_WIDTH+4,this._canvas=Ae,this._bandWidths=[],this._maxWidth=0,this._minWidth=f.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},St,e),this._opts.paletteLimit>f.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${f.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(Ye));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),f.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),f.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,f.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,f.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=f.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let s=!1;if(e)if(e!==this._minWidth)s=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!s)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let s=0;s{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let s=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(s):i.add(s)),s},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function r(s,n){return s(a=>n.fire(a))}A.forward=r;function e(s,n){return(a,o,g)=>s(h=>a.call(o,n(h)),void 0,g)}A.map=e;function t(...s){return(n,a,o)=>{let g=new N;for(let h of s)g.add(h(I=>n.call(a,I)));return o&&(Array.isArray(o)?o.push(g):o.add(g)),g}}A.any=t;function i(s,n,a){return n(a),s(o=>n(o))}A.runAndSubscribe=i})(ot||={});var de=M(X());var D=class r extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new W);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let s=(t??document).createElement("canvas");return s.width=i|0,s.height=A|0,s}static createImageData(t,i,A,s){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return s&&n.data.set(new Uint8ClampedArray(s,0,i*A*4)),n}return s?new ImageData(new Uint8ClampedArray(s,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let s=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,s,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,s,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,s,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:g}=this.cellSize;if(o===-1||g===-1)return;this._rescaleImage(t,o,g);let h=t.actual,I=Math.ceil(h.width/o),d=i%I*o,c=Math.floor(i/I)*g,l=A*o,E=s*g,p=n*o+d>h.width?h.width-d:n*o,u=c+g>h.height?h.height-c:g;a.drawImage(h,Math.floor(d),Math.floor(c),Math.ceil(p),Math.ceil(u),Math.floor(l),Math.floor(E),Math.ceil(p),Math.ceil(u))}extractTile(t,i){let{width:A,height:s}=this.cellSize;if(A===-1||s===-1)return;this._rescaleImage(t,A,s);let n=t.actual,a=Math.ceil(n.width/A),o=i%a*A,g=Math.floor(i/a)*s,h=A+o>n.width?n.width-o:A,I=g+s>n.height?n.height-g:s,d=r.createCanvas(this.document,h,I),c=d.getContext("2d");if(c)return c.drawImage(n,Math.floor(o),Math.floor(g),Math.floor(h),Math.floor(I),0,0,Math.floor(h),Math.floor(I)),d}drawPlaceholder(t,i,A=1){let s=this._layers.get("top");if(s){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;s.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:s,height:n}=t.origCellSize;if(i===s&&A===n){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let a=r.createCanvas(this.document,Math.ceil(t.orig.width*i/s),Math.ceil(t.orig.height*A/n)),o=a.getContext("2d");o&&(o.drawImage(t.orig,0,0,a.width,a.height),t.actual=a,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=r.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let s=i.getContext("2d",{alpha:!0});if(!s){i.remove();return}this._layers.set(t,s),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=r.createCanvas(this.document,i,t),s=A.getContext("2d",{alpha:!1});if(!s)return;let n=r.createImageData(s,i,t),a=new Uint32Array(n.data.buffer),o=(0,de.toRGBA8888)(0,0,0),g=(0,de.toRGBA8888)(255,255,255);a.fill(o);for(let d=0;dthis._placeholderBitmap=d)}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class r{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new r(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,Z=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),s=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,g=this._terminal.rows,h=a.x,I=a.y,d=h,c=0;t.scrolling||(a.x=0,a.y=0,d=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let u=0;u=o);++Q)this._writeToCell(C,d+Q,n,u*A+Q),c++;if(t.scrolling)u=g)break;a.x=d}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(d+A,o):a.x=d:(a.x=h,a.y=I);let l=[];for(let[u,C]of this._images.entries())C.tileCount<1&&(C.marker?.dispose(),l.push(u));for(let u of l)this._delImg(u);let E=this._terminal.registerMarker(0);E?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let p={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:E||void 0,tileCount:c,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,p),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let h of this._images.values())if(h.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:s}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,s);let o=[],g=[];for(let h=A;h<=s;++h){let I=n.lines.get(h+n.ydisp);if(!I)return;for(let d=0;dh.imgSpec.zIndex-I.imgSpec.zIndex);for(let h of g)this._renderer.drawPlaceholder(h.col,h.row,h.count);for(let h of o)this._renderer.draw(h.imgSpec,h.tileId,h.col,h.row,h.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let s=0;s=h)continue;let I=!1;for(let l=A+1;l>e.cols;++l)if(n._data[l*3+0]&4194303){I=!0;break}if(I)continue;let d=Math.min(e.cols,h-a.tileId%h+A),c=a.tileId;for(let l=A+1;l57)throw new Error("illegal char");e=e*10+r[t]-48}return e}function ke(r){let e=j(r);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function wt(r){if(typeof Buffer<"u")return Buffer.from(j(r),"base64").toString();let e=atob(j(r)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=De)return this._a();n[s++]=o}break;case 58:return A===3&&!this._storeValue(s)?this._a():(this.state=4,a+1);default:if(s>=De)return this._a();n[s++]=o}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=j(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Ne[this._key]?Ne[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function Ge(r){if(r.length<32)return L;let e=new Uint32Array(r.buffer,r.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:r[16]<<24|r[17]<<16|r[18]<<8|r[19],height:r[20]<<24|r[21]<<16|r[22]<<8|r[23]};if(r[0]===255&&r[1]===216&&r[2]===255){let[t,i]=bt(r);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(r[4]===55||r[4]===57)&&r[5]===97)return{mime:"image/gif",width:r[7]<<8|r[6],height:r[9]<<8|r[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:r[4]<<24|r[5]<<16|r[6]<<8|r[7],height:r[8]<<24|r[9]<<16|r[10]<<8|r[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(r[15]){case 88:return{mime:"image/webp",width:(r[24]|r[25]<<8|r[26]<<16)+1,height:(r[27]|r[28]<<8|r[29]<<16)+1};case 76:if(r[20]!==47)return L;let t=r[21]|r[22]<<8|r[23]<<16|r[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return r[23]!==157||r[24]!==1||r[25]!==42?L:{mime:"image/webp",width:(r[26]|r[27]<<8)&16383,height:(r[28]|r[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(r.length-16,1024);for(let A=8;A0&&s>0)return{mime:"image/avif",width:A,height:s}}return L}return L}function bt(r){let e=r.length,t=4,i=r[t]<<8|r[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(r[t]!==255)return[0,0];if(r[t+1]===192||r[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},we,this._hp.fields),!this._header.inline){this._aborted=!0;return}this._dec.init()}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let o=w.width,g=w.height;this._renderer.dimensions&&(o=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,g=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let h=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${g.toFixed(3)};${o.toFixed(3)};${h.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},we,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,s,n=L;if((s=e)&&((s=!this._dec.end())?(n=Ge(this._dec.data8),(s=n.mime!=="unsupported")?(i=n.width,A=n.height,(s=i&&A&&i*A(this._storage.addImage(o),!0)).catch(o=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,o),!0))}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,s=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,s,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let g=s/e,h=(n-A)/t,I=Math.min(g,h);return I<1?[e*I,t*I]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var Pe=M(ue());function be(r){let e={},t=r.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let s=i.substring(0,A),n=i.substring(A+1);if(s==="a"){e.action=n;continue}if(s==="o"){e.compression=n;continue}if(s==="t"){e.transmission=n;continue}if(s==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(s){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var Je=0,ee=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=s,!this._inControlData){if(this._parsedCommand=be(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??s?.decoder;o&&o.release(),this._activeDecoder=null,s&&this._removePendingEntry(A),this._aborted=!0;return}this._decodeError||(s?.decoder&&!this._activeDecoder&&(this._activeDecoder=s.decoder),this._activeDecoder||(this._activeDecoder=new Pe.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()),this._activeDecoder.put(e.subarray(t,i))!==Je&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,s&&this._removePendingEntry(A)))}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,s=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(s?(s.totalEncodedSize+=this._totalEncodedSize,s.decodeError=s.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;s&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;s&&(a=s.cmd,o=s.decoder,n=n||s.decodeError,this._pendingTransmissions.delete(i));let g=new Uint8Array(0);o&&(o.end()!==Je&&(n=!0),g=o.data8),this._activeDecoder=null;let h=this._handleCommandWithBytesAndCmd(a,g,n);return o&&o.release(),h}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),s}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(s=>(this._sendResponse(t,s?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,s=this._kittyStorage.getImage(A);if(s){let n=this._displayImage(s,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,s=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",s),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",s),!0;if(t.length===0)return this._sendResponse(A,"OK",s),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",s);else{let o=e.width??0,g=e.height??0;if(!o||!g)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",s),!0;let h=a===32?4:3,I=o*g*h;if(t.length=1||!s&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=await this._createBitmap(e);try{let A=Math.max(0,t.x??0),s=Math.max(0,t.y??0),n=t.sourceWidth||i.width-A,a=t.sourceHeight||i.height-s,o=Math.max(0,i.width-A),g=Math.max(0,i.height-s),h=Math.max(0,Math.min(n,o)),I=Math.max(0,Math.min(a,g));if(h===0||I===0)throw new Error("invalid source rectangle");if(A!==0||s!==0||h!==i.width||I!==i.height){let T=await createImageBitmap(i,A,s,h,I);i.close(),i=T}let d=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,l,E;t.columns!==void 0&&t.rows!==void 0?(l=t.columns,E=t.rows):t.columns!==void 0?(l=t.columns,E=Math.max(1,Math.ceil(i.height/i.width*(l*d)/c))):t.rows!==void 0?(E=t.rows,l=Math.max(1,Math.ceil(i.width/i.height*(E*c)/d))):(l=Math.ceil(i.width/d),E=Math.ceil(i.height/c));let p=i.width,u=i.height;if((t.columns!==void 0||t.rows!==void 0)&&(p=Math.round(l*d),u=Math.round(E*c)),p*u>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let C=this._coreTerminal._core.buffer,Q=C.x,K=C.y,Y=C.ybase,Ve=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(p!==i.width||u!==i.height){let T=await createImageBitmap(i,{resizeWidth:p,resizeHeight:u});i.close(),i=T}let oe=Math.min(Math.max(0,t.xOffset??0),d-1),he=Math.min(Math.max(0,t.yOffset??0),c-1);if(oe!==0||he!==0){let T=t.columns!==void 0?Math.round(l*d):i.width+oe,et=t.rows!==void 0?Math.round(E*c):i.height+he,q=D.createCanvas(window.document,T,et),Le=q.getContext("2d");if(!Le)throw new Error("Failed to create offset canvas context");Le.drawImage(i,oe,he);let tt=await createImageBitmap(q);if(q.width=q.height=0,i.close(),i=tt,p=i.width,u=i.height,p*u>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(l=Math.ceil(i.width/d)),t.rows===void 0&&(E=Math.ceil(i.height/c))}let $e=t.zIndex??0;if(this._kittyStorage.addImage(e.id,i,!0,Ve,$e),i=void 0,t.cursorMovement===1){let T=C.ybase-Y;C.x=Q,C.y=Math.max(K-T,0)}else C.x=Math.min(Q+l,this._coreTerminal.cols)}catch(A){throw i?.close(),A}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let p=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let u=URL.createObjectURL(p),C=new Image;return new Promise((Q,K)=>{C.addEventListener("load",()=>{URL.revokeObjectURL(u);let Y=D.createCanvas(window.document,C.width,C.height);Y.getContext("2d")?.drawImage(C,0,0),createImageBitmap(Y).then(Q).catch(K)}),C.addEventListener("error",()=>{URL.revokeObjectURL(u),K(new Error("Failed to load image"))}),C.src=u})}return createImageBitmap(p)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let s=e.format===32?4:3,n=i*A*s;if(t.length>>24|C<<8,h[c++]=4278190080|C>>>16|Q<<16,h[c++]=4278190080|Q>>>8}let l=I*3,E=I*4;for(let p=I;ph+I.length,0),o=new Uint8Array(a),g=0;for(let h of s)o.set(h,g),g+=h.length;return o}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);return A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages(),this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,s){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var te=U;var y=M(X());var ze=M(We());var xt=4194304,Se=y.PALETTE_ANSI_256;Se.set(y.PALETTE_VT340_COLOR);var se=class{constructor(e,t,i){this._opts=e;this._storage=t;this._coreTerminal=i;this._size=0;this._aborted=!1;(0,ze.DecoderAsync)({memoryLimit:this._opts.pixelLimit*4,palette:Se,paletteLimit:this._opts.sixelPaletteLimit}).then(A=>this._dec=A)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,Se,this._opts.sixelPaletteLimit))}hook(e){if(this._size=0,this._aborted=!1,this._dec){let t=e.params[1]===1?0:Mt(this._coreTerminal._core._inputHandler._curAttrData,this._coreTerminal._core._themeService?.colors);this._dec.init(t,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!(this._aborted||!this._dec)){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._dec.memoryUsage>xt&&this._dec.release(),this._storage.addImage(A),!0}};function Mt(r,e){let t=0;if(!e)return t;if(r.isInverse())if(r.isFgDefault())t=re(e.foreground.rgba);else if(r.isFgRGB()){let i=r.constructor.toColorRGB(r.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=re(e.ansi[r.getFgColor()].rgba);else if(r.isBgDefault())t=re(e.background.rgba);else if(r.isBgRGB()){let i=r.constructor.toColorRGB(r.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=re(e.ansi[r.getBgColor()].rgba);return t}function re(r){return y.BIG_ENDIAN?r:(r&255)<<24|(r>>>8&255)<<16|(r>>>16&255)<<8|r>>>24&255}var ne=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new ne(this._storage,this._opts,this._renderer,e),i=new se(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new ae(this._storage),i=new $(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new te(this._storage),i=new ee(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=Ze?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${Ze}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let s=w;t=(this._terminal?.cols||80)*s.width,i=(this._terminal?.rows||24)*s.height}if(t*i()=>{try{return e||s((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var ot=(s,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of rt(e))!at.call(s,A)&&A!==t&&Me(s,A,{get:()=>e[A],enumerable:!(i=st(e,A))||i.enumerable});return s};var M=(s,e,t)=>(t=s!=null?At(nt(s)):{},ot(e||!s||!s.__esModule?Me(t,"default",{value:s,enumerable:!0}):t,s));var Z=x(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function ve(s){return s&255}_.red=ve;function Re(s){return s>>>8&255}_.green=Re;function ke(s){return s>>>16&255}_.blue=ke;function gt(s){return s>>>24&255}_.alpha=gt;function f(s,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|s&255)>>>0}_.toRGBA8888=f;function dt(s){return[s&255,s>>8&255,s>>16&255,s>>>24]}_.fromRGBA8888=dt;function It(s,e){let t=ve(s),i=Re(s),A=ke(s),r=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(s-e)*6*t:t*2<1?s:t*3<2?e+(s-e)*(4-t*6):e}function lt(s,e,t){if(!t){let r=Math.round(e*255);return f(r,r,r)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return f(Ie(0,255,Math.round(le(i,A,s+1/3)*255)),Ie(0,255,Math.round(le(i,A,s)*255)),Ie(0,255,Math.round(le(i,A,s-1/3)*255)))}function m(s,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(s/100*255))>>>0}_.normalizeRGB=m;function ct(s,e,t){return lt((s+240%360)/360,e/100,t/100)}_.normalizeHLS=ct;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let s=[f(0,0,0),f(205,0,0),f(0,205,0),f(205,205,0),f(0,0,238),f(205,0,205),f(0,250,205),f(229,229,229),f(127,127,127),f(255,0,0),f(0,255,0),f(255,255,0),f(92,92,255),f(255,0,255),f(0,255,255),f(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)s.push(f(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)s.push(f(t,t,t));return new Uint32Array(s)})();_.DEFAULT_BACKGROUND=f(0,0,0,255);_.DEFAULT_FOREGROUND=f(255,255,255,255)});var _e=x(me=>{"use strict";Object.defineProperty(me,"__esModule",{value:!0});me.InWasm=pt;var v=s=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(s);if(typeof Buffer<"u")return Buffer.from(s,"base64");let e=atob(s),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(r||(r=new n.Module(A||(A=v(i)))),a):a=>r?n.instantiate(r,a):n.instantiate(A||(A=v(i)),a).then(o=>(r=o.module)&&o.instance):e===1?t?()=>r||(r=new n.Module(A||(A=v(i)))):()=>r?Promise.resolve(r):n.compile(A||(A=v(i))).then(a=>r=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(s)}});var Ce=x(Ee=>{"use strict";Object.defineProperty(Ee,"__esModule",{value:!0});var Bt=_e(),ft=(0,Bt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(s=>s.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let s=0;s>4|(s<<4&255)<<8;for(let s=0;s>2<<8|(s<<6&255)<<16;for(let s=0;sthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):Qt}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};Ee.default=ue});var Ne=x(Be=>{"use strict";Object.defineProperty(Be,"__esModule",{value:!0});var Dt=_e(),wt=(0,Dt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),pe=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,r=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Be.default=pe});var Ye=x(se=>{"use strict";Object.defineProperty(se,"__esModule",{value:!0});se.LIMITS=void 0;se.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF"}});var We=x(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=Z(),Q=Ye();function St(s){if(typeof Buffer<"u")return Buffer.from(s,"base64");let e=atob(s),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},Lt={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:Q.LIMITS.PALETTE_SIZE,truncate:!0};function ze(s){let e=new Le,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||qe,t).then(i=>(J=J||i.module,new P(s,i.instance||i,e)))}b.DecoderAsync=ze;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=Q.LIMITS.MAX_WIDTH+4,this._canvas=re,this._bandWidths=[],this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},Lt,e),this._opts.paletteLimit>Q.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${Q.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(qe));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),Q.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),Q.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,Q.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,r=0;for(;r<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*r,t*r+e),i+e*r),r++,A--;this._lastOffset+=e*r,this._currentHeight+=r}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,Q.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,r=0;for(;r<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*r,i*r+e),A+e*r),r++,t--;t&&this._canvas.fill(this._fillColor,A+e*r)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let r=!1;if(e)if(e!==this._minWidth)r=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!r)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let r=0;r{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let r=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function s(r,n){return r(a=>n.fire(a))}A.forward=s;function e(r,n){return(a,o,h)=>r(g=>a.call(o,n(g)),void 0,h)}A.map=e;function t(...r){return(n,a,o)=>{let h=new N;for(let g of r)h.add(g(d=>n.call(a,d)));return o&&(Array.isArray(o)?o.push(h):o.add(h)),h}}A.any=t;function i(r,n,a){return n(a),r(o=>n(o))}A.runAndSubscribe=i})(ht||={});var ce=M(Z());var D=class s extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new W);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let r=(t??document).createElement("canvas");return r.width=i|0,r.height=A|0,r}static createImageData(t,i,A,r){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return r&&n.data.set(new Uint8ClampedArray(r,0,i*A*4)),n}return r?new ImageData(new Uint8ClampedArray(r,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let r=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,r,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,r,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,r,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:h}=this.cellSize;if(o===-1||h===-1)return;this._rescaleImage(t,o,h);let g=t.actual,{width:d,height:I}=t.actualCellSize,l=Math.ceil(g.width/d),c=i%l*d,u=Math.floor(i/l)*I,E=A*o,C=r*h,p=n*d+c>g.width?g.width-c:n*d,B=u+I>g.height?g.height-u:I;a.drawImage(g,Math.floor(c),Math.floor(u),Math.ceil(p),Math.ceil(B),Math.floor(E),Math.floor(C),Math.ceil(p*o/d),Math.ceil(B*h/I))}extractTile(t,i){let{width:A,height:r}=this.cellSize;if(A===-1||r===-1)return;this._rescaleImage(t,A,r);let n=t.actual,{width:a,height:o}=t.actualCellSize,h=Math.ceil(n.width/a),g=i%h*a,d=Math.floor(i/h)*o,I=a+g>n.width?n.width-g:a,l=d+o>n.height?n.height-d:o,c=s.createCanvas(this.document,Math.ceil(I*A/a),Math.ceil(l*r/o)),u=c.getContext("2d");if(u)return u.drawImage(n,Math.floor(g),Math.floor(d),Math.floor(I),Math.floor(l),0,0,c.width,c.height),c}drawPlaceholder(t,i,A=1){let r=this._layers.get("top");if(r){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;r.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:r,height:n}=t.origCellSize;if(i===r&&A===n){t.actual=t.orig,t.actualCellSize.width=r,t.actualCellSize.height=n;return}let a=Math.ceil(t.orig.width*i/r),o=Math.ceil(t.orig.height*A/n);if(a*o>t.orig.width*t.orig.height){t.actual=t.orig,t.actualCellSize.width=r,t.actualCellSize.height=n;return}let h=s.createCanvas(this.document,a,o),g=h.getContext("2d");g&&(g.drawImage(t.orig,0,0,h.width,h.height),t.actual=h,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=s.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let r=i.getContext("2d",{alpha:!0});if(!r){i.remove();return}this._layers.set(t,r),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=s.createCanvas(this.document,i,t),r=A.getContext("2d",{alpha:!1});if(!r)return;let n=s.createImageData(r,i,t),a=new Uint32Array(n.data.buffer),o=(0,ce.toRGBA8888)(0,0,0),h=(0,ce.toRGBA8888)(255,255,255);a.fill(o);for(let l=0;l{this._placeholder!==I?l?.close():this._placeholderBitmap=l}).catch(()=>{})}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class s{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,j=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),r=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,h=this._terminal.rows,g=a.x,d=a.y,I=g,l=0;t.scrolling||(a.x=0,a.y=0,I=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let C=0;C=o);++B)this._writeToCell(p,I+B,n,C*A+B),l++;if(t.scrolling)C=h)break;a.x=I}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(I+A,o):a.x=I:(a.x=g,a.y=d);let c=[];for(let[C,p]of this._images.entries())p.tileCount<1&&(p.marker?.dispose(),c.push(C));for(let C of c)this._delImg(C);let u=this._terminal.registerMarker(0);u?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let E={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:u||void 0,tileCount:l,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,E),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let g of this._images.values())if(g.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:r}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,r);let o=[],h=[];for(let g=A;g<=r;++g){let d=n.lines.get(g+n.ydisp);if(!d)return;for(let I=0;Ig.imgSpec.zIndex-d.imgSpec.zIndex);for(let g of h)this._renderer.drawPlaceholder(g.col,g.row,g.count);for(let g of o)this._renderer.draw(g.imgSpec,g.tileId,g.col,g.row,g.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let r=0;r=g)continue;let d=!1;for(let c=A+1;c>e.cols;++c)if(n._data[c*3+0]&4194303){d=!0;break}if(d)continue;let I=Math.min(e.cols,g-a.tileId%g+A),l=a.tileId;for(let c=A+1;c57)throw new Error("illegal char");e=e*10+s[t]-48}return e}function He(s){let e=V(s);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function bt(s){if(typeof Buffer<"u")return Buffer.from(V(s),"base64").toString();let e=atob(V(s)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=be)return this._a();n[r++]=o}break;case 58:return A===3&&!this._storeValue(r)?this._a():(this.state=4,a+1);default:if(r>=be)return this._a();n[r++]=o}}return this.state=A,this._position=r,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=V(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Fe[this._key]?Fe[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function ee(s){if(s.length<32)return L;let e=new Uint32Array(s.buffer,s.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:s[16]<<24|s[17]<<16|s[18]<<8|s[19],height:s[20]<<24|s[21]<<16|s[22]<<8|s[23]};if(s[0]===255&&s[1]===216&&s[2]===255){let[t,i]=yt(s);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(s[4]===55||s[4]===57)&&s[5]===97)return{mime:"image/gif",width:s[7]<<8|s[6],height:s[9]<<8|s[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:s[4]<<24|s[5]<<16|s[6]<<8|s[7],height:s[8]<<24|s[9]<<16|s[10]<<8|s[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(s[15]){case 88:return{mime:"image/webp",width:(s[24]|s[25]<<8|s[26]<<16)+1,height:(s[27]|s[28]<<8|s[29]<<16)+1};case 76:if(s[20]!==47)return L;let t=s[21]|s[22]<<8|s[23]<<16|s[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return s[23]!==157||s[24]!==1||s[25]!==42?L:{mime:"image/webp",width:(s[26]|s[27]<<8)&16383,height:(s[28]|s[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(s.length-16,1024);for(let A=8;A0&&r>0)return{mime:"image/avif",width:A,height:r}}return L}return L}function yt(s){let e=s.length,t=4,i=s[t]<<8|s[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(s[t]!==255)return[0,0];if(s[t+1]===192||s[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},ye,this._hp.fields),!this._header.inline){this._aborted=!0;return}this._dec.init()}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let h=w.width,g=w.height;this._renderer.dimensions&&(h=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,g=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let d=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${g.toFixed(3)};${h.toFixed(3)};${d.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},ye,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,r,n=L;if((r=e)&&((r=!this._dec.end())?(n=ee(this._dec.data8),(r=n.mime!=="unsupported")?(i=n.width,A=n.height,(r=i&&A&&i*Ao!==this._generation?(h.close(),!0):(this._storage.addImage(h),!0)).catch(h=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,h),!0))}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,r=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,r,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let h=r/e,g=(n-A)/t,d=Math.min(h,g);return d<1?[e*d,t*d]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var Ke=M(Ce());function Se(s){let e={},t=s.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let r=i.substring(0,A),n=i.substring(A+1);if(r==="a"){e.action=n;continue}if(r==="o"){e.compression=n;continue}if(r==="t"){e.transmission=n;continue}if(r==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(r){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var Pe=0,ie=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._generation=0;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._generation++,this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=Se(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??r?.decoder;o&&o.release(),this._activeDecoder=null,r&&this._removePendingEntry(A),this._aborted=!0;return}if(!this._decodeError){if(r?.decoder&&!this._activeDecoder&&(this._activeDecoder=r.decoder),!this._activeDecoder){let o=this._maxEncodedBytes+131072;if(o>this._opts.storageLimit*1e6){this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",this._parsedCommand.quiet??0);return}let h=Math.max(1,Math.floor(this._opts.storageLimit*1e6/o));for(;this._pendingTransmissions.size>=h;){let g=this._pendingTransmissions.entries().next().value;if(!g)break;g[1].decoder.release(),this._removePendingEntry(g[0]),g[1].cmd.id!==void 0&&this._sendResponse(g[1].cmd.id,"ENOMEM:pending image budget exceeded",g[1].cmd.quiet??0)}this._activeDecoder=new Ke.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()}this._activeDecoder.put(e.subarray(t,i))!==Pe&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,r&&this._removePendingEntry(A))}}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,r=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(r?(r.totalEncodedSize+=this._totalEncodedSize,r.decodeError=r.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;r&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;r&&(a=r.cmd,o=r.decoder,n=n||r.decodeError,this._pendingTransmissions.delete(i));let h=new Uint8Array(0);o&&(o.end()!==Pe&&(n=!0),h=o.data8),this._activeDecoder=null;let g=this._handleCommandWithBytesAndCmd(a,h,n);return o&&o.release(),g}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),r}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(r=>(this._sendResponse(t,r?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,r=this._kittyStorage.getImage(A);if(r){let n=this._displayImage(r,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,r=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",r),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",r),!0;if(t.length===0)return this._sendResponse(A,"OK",r),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",r);else{let o=e.width??0,h=e.height??0;if(!o||!h)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",r),!0;let g=a===32?4:3,d=o*h*g;if(t.length=1||!r&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=this._generation,A=await this._createBitmap(e);try{if(i!==this._generation)throw new Error("image decode canceled");let r=Math.max(0,t.x??0),n=Math.max(0,t.y??0),a=t.sourceWidth||A.width-r,o=t.sourceHeight||A.height-n,h=Math.max(0,A.width-r),g=Math.max(0,A.height-n),d=Math.max(0,Math.min(a,h)),I=Math.max(0,Math.min(o,g));if(d===0||I===0)throw new Error("invalid source rectangle");if(r!==0||n!==0||d!==A.width||I!==A.height){let T=await createImageBitmap(A,r,n,d,I);A.close(),A=T}let l=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,u,E;t.columns!==void 0&&t.rows!==void 0?(u=t.columns,E=t.rows):t.columns!==void 0?(u=t.columns,E=Math.max(1,Math.ceil(A.height/A.width*(u*l)/c))):t.rows!==void 0?(E=t.rows,u=Math.max(1,Math.ceil(A.width/A.height*(E*c)/l))):(u=Math.ceil(A.width/l),E=Math.ceil(A.height/c));let C=A.width,p=A.height;if((t.columns!==void 0||t.rows!==void 0)&&(C=Math.round(u*l),p=Math.round(E*c)),C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let B=this._coreTerminal._core.buffer,K=B.x,Y=B.y,q=B.ybase,$e=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(C!==A.width||p!==A.height){let T=await createImageBitmap(A,{resizeWidth:C,resizeHeight:p});A.close(),A=T}let ge=Math.min(Math.max(0,t.xOffset??0),l-1),de=Math.min(Math.max(0,t.yOffset??0),c-1);if(ge!==0||de!==0){let T=t.columns!==void 0?Math.round(u*l):A.width+ge,tt=t.rows!==void 0?Math.round(E*c):A.height+de,z=D.createCanvas(window.document,T,tt),xe=z.getContext("2d");if(!xe)throw new Error("Failed to create offset canvas context");xe.drawImage(A,ge,de);let it=await createImageBitmap(z);if(z.width=z.height=0,A.close(),A=it,C=A.width,p=A.height,C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(u=Math.ceil(A.width/l)),t.rows===void 0&&(E=Math.ceil(A.height/c))}if(i!==this._generation)throw new Error("image decode canceled");let et=t.zIndex??0;if(this._kittyStorage.addImage(e.id,A,!0,$e,et),A=void 0,t.cursorMovement===1){let T=B.ybase-q;B.x=K,B.y=Math.max(Y-T,0)}else B.x=Math.min(K+u,this._coreTerminal.cols)}catch(r){throw A?.close(),r}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let E=ee(t);if(E.mime!=="image/png"||!(E.width>0)||!(E.height>0)||E.width*E.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");let C=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let p=URL.createObjectURL(C),B=new Image;return new Promise((K,Y)=>{B.addEventListener("load",()=>{URL.revokeObjectURL(p);let q=D.createCanvas(window.document,B.width,B.height);q.getContext("2d")?.drawImage(B,0,0),createImageBitmap(q).then(K).catch(Y)}),B.addEventListener("error",()=>{URL.revokeObjectURL(p),Y(new Error("Failed to load image"))}),B.src=p})}return createImageBitmap(C)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let r=e.format===32?4:3,n=i*A*r;if(t.length>>24|p<<8,g[l++]=4278190080|p>>>16|B<<16,g[l++]=4278190080|B>>>8}let c=d*3,u=d*4;for(let E=d;E=e.length){d.close();return}let I=Math.min(A+4096,e.length);d.enqueue(new Uint8Array(e.subarray(A,I))),A=I}}).pipeThrough(new DecompressionStream(t)).getReader(),a=[],o=0;try{for(;;){let{done:d,value:I}=await n.read();if(d)break;if(o+=I.byteLength,o>i)throw await n.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");a.push(I)}}finally{n.releaseLock()}let h=new Uint8Array(o),g=0;for(let d of a)h.set(d,g),g+=d.length;return h}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages();let r=this._storage.getLimit()*1e6;this._images.delete(i);let n=0;for(let a of this._images.values())n+=a.data.size;for(let a of[!1,!0])for(let[o,h]of this._images){if(n+t.data.size<=r)break;this._kittyIdToStorageId.has(o)===a&&(n-=h.data.size,this.deleteById(o))}return this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,r){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=r;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var Ae=U;var y=M(Z());var Xe=M(We());var Mt=4194304,Te=y.PALETTE_ANSI_256;Te.set(y.PALETTE_VT340_COLOR);var ae=class{constructor(e,t,i){this._opts=e;this._storage=t;this._coreTerminal=i;this._size=0;this._aborted=!1;(0,Xe.DecoderAsync)({memoryLimit:this._opts.pixelLimit*4,palette:Te,paletteLimit:this._opts.sixelPaletteLimit}).then(A=>this._dec=A)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,Te,this._opts.sixelPaletteLimit))}hook(e){if(this._size=0,this._aborted=!1,this._dec){let t=e.params[1]===1?0:vt(this._coreTerminal._core._inputHandler._curAttrData,this._coreTerminal._core._themeService?.colors);this._dec.init(t,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!(this._aborted||!this._dec)){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._dec.memoryUsage>Mt&&this._dec.release(),this._storage.addImage(A),!0}};function vt(s,e){let t=0;if(!e)return t;if(s.isInverse())if(s.isFgDefault())t=ne(e.foreground.rgba);else if(s.isFgRGB()){let i=s.constructor.toColorRGB(s.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=ne(e.ansi[s.getFgColor()].rgba);else if(s.isBgDefault())t=ne(e.background.rgba);else if(s.isBgRGB()){let i=s.constructor.toColorRGB(s.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=ne(e.ansi[s.getBgColor()].rgba);return t}function ne(s){return y.BIG_ENDIAN?s:(s&255)<<24|(s>>>8&255)<<16|(s>>>16&255)<<8|s>>>24&255}var oe=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new oe(this._storage,this._opts,this._renderer,e),i=new ae(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new he(this._storage),i=new te(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new Ae(this._storage),i=new ie(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=je?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${je}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let r=w;t=(this._terminal?.cols||80)*r.width,i=(this._terminal?.rows||24)*r.height}if(t*i()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var It=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of ht(e))!gt.call(r,A)&&A!==t&&Ne(r,A,{get:()=>e[A],enumerable:!(i=ot(e,A))||i.enumerable});return r};var x=(r,e,t)=>(t=r!=null?at(dt(r)):{},It(e||!r||!r.__esModule?Ne(t,"default",{value:r,enumerable:!0}):t,r));var j=M(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function He(r){return r&255}_.red=He;function Fe(r){return r>>>8&255}_.green=Fe;function Ge(r){return r>>>16&255}_.blue=Ge;function ct(r){return r>>>24&255}_.alpha=ct;function f(r,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|r&255)>>>0}_.toRGBA8888=f;function mt(r){return[r&255,r>>8&255,r>>16&255,r>>>24]}_.fromRGBA8888=mt;function _t(r,e){let t=He(r),i=Fe(r),A=Ge(r),s=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(r-e)*6*t:t*2<1?r:t*3<2?e+(r-e)*(4-t*6):e}function ut(r,e,t){if(!t){let s=Math.round(e*255);return f(s,s,s)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return f(ce(0,255,Math.round(me(i,A,r+1/3)*255)),ce(0,255,Math.round(me(i,A,r)*255)),ce(0,255,Math.round(me(i,A,r-1/3)*255)))}function m(r,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(r/100*255))>>>0}_.normalizeRGB=m;function Et(r,e,t){return ut((r+240%360)/360,e/100,t/100)}_.normalizeHLS=Et;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let r=[f(0,0,0),f(205,0,0),f(0,205,0),f(205,205,0),f(0,0,238),f(205,0,205),f(0,250,205),f(229,229,229),f(127,127,127),f(255,0,0),f(0,255,0),f(255,255,0),f(92,92,255),f(255,0,255),f(0,255,255),f(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)r.push(f(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)r.push(f(t,t,t));return new Uint32Array(r)})();_.DEFAULT_BACKGROUND=f(0,0,0,255);_.DEFAULT_FOREGROUND=f(255,255,255,255)});var Ee=M(ue=>{"use strict";Object.defineProperty(ue,"__esModule",{value:!0});ue.InWasm=Dt;var v=r=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(r);if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(s||(s=new n.Module(A||(A=v(i)))),a):a=>s?n.instantiate(s,a):n.instantiate(A||(A=v(i)),a).then(o=>(s=o.module)&&o.instance):e===1?t?()=>s||(s=new n.Module(A||(A=v(i)))):()=>s?Promise.resolve(s):n.compile(A||(A=v(i))).then(a=>s=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(r)}});var Be=M(pe=>{"use strict";Object.defineProperty(pe,"__esModule",{value:!0});var wt=Ee(),bt=(0,wt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(r=>r.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let r=0;r>4|(r<<4&255)<<8;for(let r=0;r>2<<8|(r<<6&255)<<16;for(let r=0;rthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):yt}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};pe.default=Ce});var Ue=M(Qe=>{"use strict";Object.defineProperty(Qe,"__esModule",{value:!0});var St=Ee(),Lt=(0,St.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),fe=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,s=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Qe.default=fe});var xe=M(se=>{"use strict";Object.defineProperty(se,"__esModule",{value:!0});se.LIMITS=void 0;se.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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"}});var je=M(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=j(),Q=xe();function Mt(r){if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},vt={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:Q.LIMITS.PALETTE_SIZE,truncate:!0};function Ze(r){let e=new Me,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||Xe,t).then(i=>(J=J||i.module,new P(r,i.instance||i,e)))}b.DecoderAsync=Ze;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=Q.LIMITS.MAX_WIDTH+4,this._canvas=ne,this._bandWidths=[],this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},vt,e),this._opts.paletteLimit>Q.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${Q.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(Xe));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),Q.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),Q.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,Q.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,Q.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let s=!1;if(e)if(e!==this._minWidth)s=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!s)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let s=0;s{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let s=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(s):i.add(s)),s},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function r(s,n){return s(a=>n.fire(a))}A.forward=r;function e(s,n){return(a,o,h)=>s(d=>a.call(o,n(d)),void 0,h)}A.map=e;function t(...s){return(n,a,o)=>{let h=new N;for(let d of s)h.add(d(g=>n.call(a,g)));return o&&(Array.isArray(o)?o.push(h):o.add(h)),h}}A.any=t;function i(s,n,a){return n(a),s(o=>n(o))}A.runAndSubscribe=i})(lt||={});var _e=x(j());var D=class r extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new X);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let s=(t??document).createElement("canvas");return s.width=i|0,s.height=A|0,s}static createImageData(t,i,A,s){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return s&&n.data.set(new Uint8ClampedArray(s,0,i*A*4)),n}return s?new ImageData(new Uint8ClampedArray(s,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let s=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,s,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,s,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,s,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:h}=this.cellSize;if(o===-1||h===-1)return;this._rescaleImage(t,o,h);let d=t.actual,{width:g,height:I}=t.actualCellSize,l=Math.ceil(d.width/g),c=i%l*g,u=Math.floor(i/l)*I,E=A*o,C=s*h,p=n*g+c>d.width?d.width-c:n*g,B=u+I>d.height?d.height-u:I;a.drawImage(d,Math.floor(c),Math.floor(u),Math.ceil(p),Math.ceil(B),Math.floor(E),Math.floor(C),Math.ceil(p*o/g),Math.ceil(B*h/I))}extractTile(t,i){let{width:A,height:s}=this.cellSize;if(A===-1||s===-1)return;this._rescaleImage(t,A,s);let n=t.actual,{width:a,height:o}=t.actualCellSize,h=Math.ceil(n.width/a),d=i%h*a,g=Math.floor(i/h)*o,I=a+d>n.width?n.width-d:a,l=g+o>n.height?n.height-g:o,c=r.createCanvas(this.document,Math.ceil(I*A/a),Math.ceil(l*s/o)),u=c.getContext("2d");if(u)return u.drawImage(n,Math.floor(d),Math.floor(g),Math.floor(I),Math.floor(l),0,0,c.width,c.height),c}drawPlaceholder(t,i,A=1){let s=this._layers.get("top");if(s){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;s.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:s,height:n}=t.origCellSize;if(i===s&&A===n){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let a=Math.ceil(t.orig.width*i/s),o=Math.ceil(t.orig.height*A/n);if(a*o>t.orig.width*t.orig.height){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let h=r.createCanvas(this.document,a,o),d=h.getContext("2d");d&&(d.drawImage(t.orig,0,0,h.width,h.height),t.actual=h,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=r.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let s=i.getContext("2d",{alpha:!0});if(!s){i.remove();return}this._layers.set(t,s),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=r.createCanvas(this.document,i,t),s=A.getContext("2d",{alpha:!1});if(!s)return;let n=r.createImageData(s,i,t),a=new Uint32Array(n.data.buffer),o=(0,_e.toRGBA8888)(0,0,0),h=(0,_e.toRGBA8888)(255,255,255);a.fill(o);for(let l=0;l{this._placeholder!==I?l?.close():this._placeholderBitmap=l}).catch(()=>{})}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class r{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new r(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,V=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),s=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,h=this._terminal.rows,d=a.x,g=a.y,I=d,l=0;t.scrolling||(a.x=0,a.y=0,I=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let C=0;C=o);++B)this._writeToCell(p,I+B,n,C*A+B),l++;if(t.scrolling)C=h)break;a.x=I}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(I+A,o):a.x=I:(a.x=d,a.y=g);let c=[];for(let[C,p]of this._images.entries())p.tileCount<1&&(p.marker?.dispose(),c.push(C));for(let C of c)this._delImg(C);let u=this._terminal.registerMarker(0);u?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let E={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:u||void 0,tileCount:l,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,E),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let d of this._images.values())if(d.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:s}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,s);let o=[],h=[];for(let d=A;d<=s;++d){let g=n.lines.get(d+n.ydisp);if(!g)return;for(let I=0;Id.imgSpec.zIndex-g.imgSpec.zIndex);for(let d of h)this._renderer.drawPlaceholder(d.col,d.row,d.count);for(let d of o)this._renderer.draw(d.imgSpec,d.tileId,d.col,d.row,d.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let s=0;s=d)continue;let g=!1;for(let c=A+1;c>e.cols;++c)if(n._data[c*3+0]&4194303){g=!0;break}if(g)continue;let I=Math.min(e.cols,d-a.tileId%d+A),l=a.tileId;for(let c=A+1;c57)throw new Error("illegal char");e=e*10+r[t]-48}return e}function Oe(r){let e=$(r);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function Tt(r){if(typeof Buffer<"u")return Buffer.from($(r),"base64").toString();let e=atob($(r)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=Se)return this._a();n[s++]=o}break;case 58:return A===3&&!this._storeValue(s)?this._a():(this.state=4,a+1);default:if(s>=Se)return this._a();n[s++]=o}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=$(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Je[this._key]?Je[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function te(r){if(r.length<32)return L;let e=new Uint32Array(r.buffer,r.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:r[16]<<24|r[17]<<16|r[18]<<8|r[19],height:r[20]<<24|r[21]<<16|r[22]<<8|r[23]};if(r[0]===255&&r[1]===216&&r[2]===255){let[t,i]=xt(r);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(r[4]===55||r[4]===57)&&r[5]===97)return{mime:"image/gif",width:r[7]<<8|r[6],height:r[9]<<8|r[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:r[4]<<24|r[5]<<16|r[6]<<8|r[7],height:r[8]<<24|r[9]<<16|r[10]<<8|r[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(r[15]){case 88:return{mime:"image/webp",width:(r[24]|r[25]<<8|r[26]<<16)+1,height:(r[27]|r[28]<<8|r[29]<<16)+1};case 76:if(r[20]!==47)return L;let t=r[21]|r[22]<<8|r[23]<<16|r[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return r[23]!==157||r[24]!==1||r[25]!==42?L:{mime:"image/webp",width:(r[26]|r[27]<<8)&16383,height:(r[28]|r[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(r.length-16,1024);for(let A=8;A0&&s>0)return{mime:"image/avif",width:A,height:s}}return L}return L}function xt(r){let e=r.length,t=4,i=r[t]<<8|r[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(r[t]!==255)return[0,0];if(r[t+1]===192||r[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},Le,this._hp.fields),!this._header.inline){this._aborted=!0;return}if(!this._initDecoder()){this._aborted=!0;return}}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let h=w.width,d=w.height;this._renderer.dimensions&&(h=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,d=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let g=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${d.toFixed(3)};${h.toFixed(3)};${g.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},Le,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._initDecoder()||(this._abortMulti=!0),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,s,n=L;if((s=e)&&((s=!this._dec.end())?(n=te(this._dec.data8),(s=n.mime!=="unsupported")?(i=n.width,A=n.height,(s=i&&A&&i*Ao!==this._generation?(h.close(),!0):(this._storage.addImage(h),!0)).catch(h=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,h),!0))}_initDecoder(){try{return this._dec.init(),!0}catch(e){return console.warn("IIP: could not allocate decoder",e),this._dec.release(),!1}}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,s=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,s,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let h=s/e,d=(n-A)/t,g=Math.min(h,d);return g<1?[e*g,t*g]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var We=x(Be());function Te(r){let e={},t=r.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let s=i.substring(0,A),n=i.substring(A+1);if(s==="a"){e.action=n;continue}if(s==="o"){e.compression=n;continue}if(s==="t"){e.transmission=n;continue}if(s==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(s){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var ze=0,Ae=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._generation=0;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._generation++,this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=s,!this._inControlData){if(this._parsedCommand=Te(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??s?.decoder;o&&o.release(),this._activeDecoder=null,s&&this._removePendingEntry(A),this._aborted=!0;return}if(!this._decodeError){if(s?.decoder&&!this._activeDecoder&&(this._activeDecoder=s.decoder),!this._activeDecoder){let o=this._maxEncodedBytes+131072;if(o>this._opts.storageLimit*1e6){this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",this._parsedCommand.quiet??0);return}let h=Math.max(1,Math.floor(this._opts.storageLimit*1e6/o));for(;this._pendingTransmissions.size>=h;){let g=this._pendingTransmissions.entries().next().value;if(!g)break;g[1].decoder.release(),this._removePendingEntry(g[0]),g[1].cmd.id!==void 0&&this._sendResponse(g[1].cmd.id,"ENOMEM:pending image budget exceeded",g[1].cmd.quiet??0)}let d=new We.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes);try{d.init()}catch(g){console.warn("KITTY: could not allocate decoder",g),this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:could not allocate decoder",this._parsedCommand.quiet??0);return}this._activeDecoder=d}this._activeDecoder.put(e.subarray(t,i))!==ze&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,s&&this._removePendingEntry(A))}}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,s=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(s?(s.totalEncodedSize+=this._totalEncodedSize,s.decodeError=s.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;s&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;s&&(a=s.cmd,o=s.decoder,n=n||s.decodeError,this._pendingTransmissions.delete(i));let h=new Uint8Array(0);o&&(o.end()!==ze&&(n=!0),h=o.data8),this._activeDecoder=null;let d=this._handleCommandWithBytesAndCmd(a,h,n);return o&&o.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),s}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(s=>(this._sendResponse(t,s?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,s=this._kittyStorage.getImage(A);if(s){let n=this._displayImage(s,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,s=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",s),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",s),!0;if(t.length===0)return this._sendResponse(A,"OK",s),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",s);else{let o=e.width??0,h=e.height??0;if(!o||!h)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",s),!0;let d=a===32?4:3,g=o*h*d;if(t.length=1||!s&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=this._generation,A=await this._createBitmap(e);try{if(i!==this._generation)throw new Error("image decode canceled");let s=Math.max(0,t.x??0),n=Math.max(0,t.y??0),a=t.sourceWidth||A.width-s,o=t.sourceHeight||A.height-n,h=Math.max(0,A.width-s),d=Math.max(0,A.height-n),g=Math.max(0,Math.min(a,h)),I=Math.max(0,Math.min(o,d));if(g===0||I===0)throw new Error("invalid source rectangle");if(s!==0||n!==0||g!==A.width||I!==A.height){let T=await createImageBitmap(A,s,n,g,I);A.close(),A=T}let l=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,u,E;t.columns!==void 0&&t.rows!==void 0?(u=t.columns,E=t.rows):t.columns!==void 0?(u=t.columns,E=Math.max(1,Math.ceil(A.height/A.width*(u*l)/c))):t.rows!==void 0?(E=t.rows,u=Math.max(1,Math.ceil(A.width/A.height*(E*c)/l))):(u=Math.ceil(A.width/l),E=Math.ceil(A.height/c));let C=A.width,p=A.height;if((t.columns!==void 0||t.rows!==void 0)&&(C=Math.round(u*l),p=Math.round(E*c)),C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let B=this._coreTerminal._core.buffer,Y=B.x,q=B.y,z=B.ybase,At=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(C!==A.width||p!==A.height){let T=await createImageBitmap(A,{resizeWidth:C,resizeHeight:p});A.close(),A=T}let Ie=Math.min(Math.max(0,t.xOffset??0),l-1),le=Math.min(Math.max(0,t.yOffset??0),c-1);if(Ie!==0||le!==0){let T=t.columns!==void 0?Math.round(u*l):A.width+Ie,st=t.rows!==void 0?Math.round(E*c):A.height+le,W=D.createCanvas(window.document,T,st),ke=W.getContext("2d");if(!ke)throw new Error("Failed to create offset canvas context");ke.drawImage(A,Ie,le);let nt=await createImageBitmap(W);if(W.width=W.height=0,A.close(),A=nt,C=A.width,p=A.height,C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(u=Math.ceil(A.width/l)),t.rows===void 0&&(E=Math.ceil(A.height/c))}if(i!==this._generation)throw new Error("image decode canceled");let rt=t.zIndex??0;if(this._kittyStorage.addImage(e.id,A,!0,At,rt),A=void 0,t.cursorMovement===1){let T=B.ybase-z;B.x=Y,B.y=Math.max(q-T,0)}else B.x=Math.min(Y+u,this._coreTerminal.cols)}catch(s){throw A?.close(),s}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let E=te(t);if(E.mime!=="image/png"||!(E.width>0)||!(E.height>0)||E.width*E.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");let C=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let p=URL.createObjectURL(C),B=new Image;return new Promise((Y,q)=>{B.addEventListener("load",()=>{URL.revokeObjectURL(p);let z=D.createCanvas(window.document,B.width,B.height);z.getContext("2d")?.drawImage(B,0,0),createImageBitmap(z).then(Y).catch(q)}),B.addEventListener("error",()=>{URL.revokeObjectURL(p),q(new Error("Failed to load image"))}),B.src=p})}return createImageBitmap(C)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let s=e.format===32?4:3,n=i*A*s;if(t.length>>24|p<<8,d[l++]=4278190080|p>>>16|B<<16,d[l++]=4278190080|B>>>8}let c=g*3,u=g*4;for(let E=g;E=e.length){g.close();return}let I=Math.min(A+4096,e.length);g.enqueue(new Uint8Array(e.subarray(A,I))),A=I}}).pipeThrough(new DecompressionStream(t)).getReader(),a=[],o=0;try{for(;;){let{done:g,value:I}=await n.read();if(g)break;if(o+=I.byteLength,o>i)throw await n.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");a.push(I)}}finally{n.releaseLock()}let h=new Uint8Array(o),d=0;for(let g of a)h.set(g,d),d+=g.length;return h}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages();let s=this._storage.getLimit()*1e6;this._images.delete(i);let n=0;for(let a of this._images.values())n+=a.data.size;for(let a of[!1,!0])for(let[o,h]of this._images){if(n+t.data.size<=s)break;this._kittyIdToStorageId.has(o)===a&&(n-=h.data.size,this.deleteById(o))}return this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,s){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var re=U;var y=x(j());var he=x(je()),Ve=x(xe());var Nt=4194304,K=y.PALETTE_ANSI_256;K.set(y.PALETTE_VT340_COLOR);var Ht=2,Re=new Map,ve=!1;function Ft(r){ve||(ve=!0,(0,he.DecoderAsync)({memoryLimit:r,palette:K}).then(e=>$e(e,r),()=>{ve=!1}))}function Gt(r){return Re.get(r)?.pop()??new he.Decoder({memoryLimit:r,palette:K})}function $e(r,e){r.memoryUsage>Nt&&r.release();let t=Re.get(e)??[];t.lengththis._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){try{return this._unhook(e)}finally{this._returnDecoder()}}_unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._storage.addImage(A),!0}};function Ut(r,e){let t=0;if(!e)return t;if(r.isInverse())if(r.isFgDefault())t=ae(e.foreground.rgba);else if(r.isFgRGB()){let i=r.constructor.toColorRGB(r.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=ae(e.ansi[r.getFgColor()].rgba);else if(r.isBgDefault())t=ae(e.background.rgba);else if(r.isBgRGB()){let i=r.constructor.toColorRGB(r.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=ae(e.ansi[r.getBgColor()].rgba);return t}function ae(r){return y.BIG_ENDIAN?r:(r&255)<<24|(r>>>8&255)<<16|(r>>>16&255)<<8|r>>>24&255}var de=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new de(this._storage,this._opts,this._renderer,e),i=new oe(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new ge(this._storage),i=new ie(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new re(this._storage),i=new Ae(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=tt?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${tt}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let s=w;t=(this._terminal?.cols||80)*s.width,i=(this._terminal?.rows||24)*s.height}if(t*i {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const finalWidth = width + sx > img.width ? img.width - sx : width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document,\n Math.ceil(spec.orig!.width * currentWidth / originalWidth),\n Math.ceil(spec.orig!.height * currentHeight / originalHeight)\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch {\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const ds = new DecompressionStream(format);\n const writer = ds.writable.getWriter();\n writer.write(compressed as BufferSource);\n writer.close();\n\n const chunks: Uint8Array[] = [];\n const reader = ds.readable.getReader();\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], - "mappings": "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", - "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "r", "min", "idx", "i", "dr", "dg", "db", "d", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "s", "b", "r", "def", "t", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "s", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "canvas", "ImageRenderer", "bm", "e", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "Base64Decoder", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "decoder", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "e", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "ds", "writer", "chunks", "reader", "done", "value", "totalLength", "sum", "chunk", "offset", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "image", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "SixelHandler", "_opts", "_storage", "_coreTerminal", "d", "params", "fillColor", "extractActiveBg", "data", "start", "end", "e", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "handler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] -+ "sourcesContent": [null, "\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], -+ "mappings": "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", -+ "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "r", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "min", "idx", "i", "dr", "dg", "db", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "d", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "b", "r", "def", "t", "s", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "sourceWidth", "sourceHeight", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "scaledWidth", "scaledHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "placeholder", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "canvas", "ImageRenderer", "generation", "bm", "e", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "decoderCapacity", "maxPending", "oldest", "Base64Decoder", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "decoder", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "generation", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "e", "metrics", "imageType", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "error", "limit", "offsetIn", "reader", "controller", "chunks", "totalLength", "done", "value", "offset", "chunk", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "byteLimit", "retainedBytes", "image", "evictPlaced", "oldestId", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "SixelHandler", "_opts", "_storage", "_coreTerminal", "d", "params", "fillColor", "extractActiveBg", "data", "start", "end", "e", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "handler", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] ++ "sourcesContent": [null, "\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Held memory in base64/QOI decoders between images. Zero because each kept\n // decoder pins a wasm memory, and V8 caps those per process (~124 in a\n // sandboxed renderer), so idle terminals must not hold one.\n KEEP_DATA = 0,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n if (!this._initDecoder()) {\n this._aborted = true;\n return;\n }\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n if (!this._initDecoder()) {\n this._abortMulti = true;\n }\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n let data: Uint8Array;\n try {\n data = this._qoiDec.decode(this._dec.data8);\n } catch (e) {\n console.warn('IIP: could not decode QOI image', e);\n this._dec.release();\n this._qoiDec.release();\n return true;\n }\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n private _initDecoder(): boolean {\n try {\n this._dec.init();\n return true;\n } catch (e) {\n console.warn('IIP: could not allocate decoder', e);\n this._dec.release();\n return false;\n }\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n try {\n decoder.init();\n } catch (e) {\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n console.warn('KITTY: could not allocate decoder', e);\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n this._activeDecoder = decoder;\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\nimport { LIMITS } from 'sixel/lib/wasm';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories\n// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only\n// while a SIXEL sequence is open, so idle terminals hold none.\nconst MAX_IDLE_DECODERS = 2;\nconst idleDecoders = new Map();\nlet poolPrimed = false;\n\nfunction primeDecoderPool(memoryLimit: number): void {\n if (poolPrimed) return;\n poolPrimed = true;\n // Async compile once, off the parser's hot path; later decoders reuse the cached module.\n DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(\n d => releaseDecoder(d, memoryLimit),\n () => { poolPrimed = false; }\n );\n}\n\nfunction acquireDecoder(memoryLimit: number): Decoder {\n return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });\n}\n\nfunction releaseDecoder(dec: Decoder, memoryLimit: number): void {\n if (dec.memoryUsage > MEM_PERMA_LIMIT) {\n dec.release();\n }\n const idle = idleDecoders.get(memoryLimit) ?? [];\n if (idle.length < MAX_IDLE_DECODERS) {\n idle.push(dec);\n idleDecoders.set(memoryLimit, idle);\n }\n}\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n private _decMemoryLimit = 0;\n // Color registers outlive a single image, so they live here rather than in a pooled decoder.\n private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n this._palette.set(DEFAULT_PALETTE);\n primeDecoderPool(this._opts.pixelLimit * 4);\n }\n\n public reset(): void {\n this._returnDecoder();\n this._palette.fill(0);\n this._palette.set(DEFAULT_PALETTE);\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n this._returnDecoder();\n const memoryLimit = this._opts.pixelLimit * 4;\n try {\n this._dec = acquireDecoder(memoryLimit);\n } catch (e) {\n // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.\n console.warn(`SIXEL: could not allocate decoder - ${e}`);\n this._aborted = true;\n return;\n }\n this._decMemoryLimit = memoryLimit;\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);\n }\n\n private _returnDecoder(): void {\n const dec = this._dec;\n if (!dec) return;\n this._dec = undefined;\n this._palette.set(dec.palette);\n releaseDecoder(dec, this._decMemoryLimit);\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n try {\n return this._unhook(success);\n } finally {\n this._returnDecoder();\n }\n }\n\n private _unhook(success: boolean): boolean {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], ++ "mappings": "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", ++ "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "r", "min", "idx", "i", "dr", "dg", "db", "d", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "s", "b", "r", "def", "t", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "s", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "sourceWidth", "sourceHeight", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "scaledWidth", "scaledHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "placeholder", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "e", "canvas", "ImageRenderer", "generation", "bm", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "decoderCapacity", "maxPending", "oldest", "decoder", "Base64Decoder", "e", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "generation", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "metrics", "imageType", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "error", "limit", "offsetIn", "reader", "controller", "chunks", "totalLength", "done", "value", "offset", "chunk", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "byteLimit", "retainedBytes", "image", "evictPlaced", "oldestId", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "import_wasm", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "MAX_IDLE_DECODERS", "idleDecoders", "poolPrimed", "primeDecoderPool", "memoryLimit", "d", "releaseDecoder", "acquireDecoder", "dec", "idle", "SixelHandler", "_opts", "_storage", "_coreTerminal", "params", "e", "fillColor", "extractActiveBg", "data", "start", "end", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "handler", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] } diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts -index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644 +index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644 --- a/src/IIPHandler.ts +++ b/src/IIPHandler.ts -@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = { +@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; + + // Local const enum mirror - esbuild can't inline const enums from external packages + const enum DecoderConst { +- // Limit held memory in base64 decoder (encoded bytes). +- KEEP_DATA = 4194304, ++ // Held memory in base64/QOI decoders between images. Zero because each kept ++ // decoder pins a wasm memory, and V8 caps those per process (~124 in a ++ // sandboxed renderer), so idle terminals must not hold one. ++ KEEP_DATA = 0, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) +@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = { export class IIPHandler implements IOscHandler, IResetHandler { @@ -55,7 +68,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; -@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } public reset(): void { @@ -63,7 +76,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._hp.reset(); this._dec.release(); this._qoiDec.release(); -@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._aborted = true; + return; + } +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._aborted = true; ++ return; ++ } + } else if (this._abortMulti) { + this._aborted = true; + return; +@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._isMultipart = true; + this._abortMulti = false; + this._dec.release(); +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._abortMulti = true; ++ } + return true; + } + +@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { + + let blob: Blob | ImageData; + if (metrics.mime === 'image/qoi') { +- const data = this._qoiDec.decode(this._dec.data8); ++ let data: Uint8Array; ++ try { ++ data = this._qoiDec.decode(this._dec.data8); ++ } catch (e) { ++ console.warn('IIP: could not decode QOI image', e); ++ this._dec.release(); ++ this._qoiDec.release(); ++ return true; ++ } + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, +@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); @@ -77,6 +130,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._storage.addImage(bm); return true; }) +@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler { + }); + } + ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ private _initDecoder(): boolean { ++ try { ++ this._dec.init(); ++ return true; ++ } catch (e) { ++ console.warn('IIP: could not allocate decoder', e); ++ this._dec.release(); ++ return false; ++ } ++ } ++ + private _resize(w: number, h: number): [number, number] { + const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; + const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644 --- a/src/ImageAddon.ts @@ -189,8 +261,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33 } public get document(): Document | undefined { +diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts +index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644 +--- a/src/SixelHandler.ts ++++ b/src/SixelHandler.ts +@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types'; + import { ImageRenderer } from './ImageRenderer'; + + import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; ++import { LIMITS } from 'sixel/lib/wasm'; + + // always free decoder ressources after decoding if it exceeds this limit + const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB +@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + const DEFAULT_PALETTE = PALETTE_ANSI_256; + DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); + ++// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories ++// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only ++// while a SIXEL sequence is open, so idle terminals hold none. ++const MAX_IDLE_DECODERS = 2; ++const idleDecoders = new Map(); ++let poolPrimed = false; ++ ++function primeDecoderPool(memoryLimit: number): void { ++ if (poolPrimed) return; ++ poolPrimed = true; ++ // Async compile once, off the parser's hot path; later decoders reuse the cached module. ++ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then( ++ d => releaseDecoder(d, memoryLimit), ++ () => { poolPrimed = false; } ++ ); ++} ++ ++function acquireDecoder(memoryLimit: number): Decoder { ++ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE }); ++} ++ ++function releaseDecoder(dec: Decoder, memoryLimit: number): void { ++ if (dec.memoryUsage > MEM_PERMA_LIMIT) { ++ dec.release(); ++ } ++ const idle = idleDecoders.get(memoryLimit) ?? []; ++ if (idle.length < MAX_IDLE_DECODERS) { ++ idle.push(dec); ++ idleDecoders.set(memoryLimit, idle); ++ } ++} ++ + + export class SixelHandler implements IDcsHandler, IResetHandler { + private _size = 0; + private _aborted = false; + private _dec: Decoder | undefined; ++ private _decMemoryLimit = 0; ++ // Color registers outlive a single image, so they live here rather than in a pooled decoder. ++ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE); + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: SixelImageStorage, + private readonly _coreTerminal: ITerminalExt + ) { +- DecoderAsync({ +- memoryLimit: this._opts.pixelLimit * 4, +- palette: DEFAULT_PALETTE, +- paletteLimit: this._opts.sixelPaletteLimit +- }).then(d => this._dec = d); ++ this._palette.set(DEFAULT_PALETTE); ++ primeDecoderPool(this._opts.pixelLimit * 4); + } + + public reset(): void { +- /** +- * reset sixel decoder to defaults: +- * - release all memory +- * - nullify palette (4096) +- * - apply default palette (256) +- */ +- if (this._dec) { +- this._dec.release(); +- // FIXME: missing interface on decoder to nullify full palette +- (this._dec as any)._palette.fill(0); +- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); +- } ++ this._returnDecoder(); ++ this._palette.fill(0); ++ this._palette.set(DEFAULT_PALETTE); + } + + public hook(params: IParams): void { + this._size = 0; + this._aborted = false; +- if (this._dec) { +- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( +- this._coreTerminal._core._inputHandler._curAttrData, +- this._coreTerminal._core._themeService?.colors); +- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); ++ this._returnDecoder(); ++ const memoryLimit = this._opts.pixelLimit * 4; ++ try { ++ this._dec = acquireDecoder(memoryLimit); ++ } catch (e) { ++ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn(`SIXEL: could not allocate decoder - ${e}`); ++ this._aborted = true; ++ return; + } ++ this._decMemoryLimit = memoryLimit; ++ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( ++ this._coreTerminal._core._inputHandler._curAttrData, ++ this._coreTerminal._core._themeService?.colors); ++ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit); ++ } ++ ++ private _returnDecoder(): void { ++ const dec = this._dec; ++ if (!dec) return; ++ this._dec = undefined; ++ this._palette.set(dec.palette); ++ releaseDecoder(dec, this._decMemoryLimit); + } + + public put(data: Uint32Array, start: number, end: number): void { +@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + } + + public unhook(success: boolean): boolean | Promise { ++ try { ++ return this._unhook(success); ++ } finally { ++ this._returnDecoder(); ++ } ++ } ++ ++ private _unhook(success: boolean): boolean { + if (this._aborted || !success || !this._dec) { + return true; + } +@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + + const canvas = ImageRenderer.createCanvas(undefined, width, height); + canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0); +- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { +- this._dec.release(); +- } + this._storage.addImage(canvas); + return true; + } diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts -index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644 +index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644 --- a/src/kitty/KittyGraphicsHandler.ts +++ b/src/kitty/KittyGraphicsHandler.ts @@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm'; @@ -217,10 +437,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f this._cleanupAllPending(); if (this._activeDecoder) { this._activeDecoder.release(); -@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { +- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); +- this._activeDecoder.init(); + // Budget WASM capacity, including one page of decoder state and rounding. + const decoderCapacity = this._maxEncodedBytes + 131072; + if (decoderCapacity > this._opts.storageLimit * 1000000) { @@ -240,10 +462,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f + this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0); + } + } - this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); - this._activeDecoder.init(); ++ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); ++ try { ++ decoder.init(); ++ } catch (e) { ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn('KITTY: could not allocate decoder', e); ++ this._aborted = true; ++ if (this._parsedCommand?.id !== undefined) { ++ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0); ++ } ++ return; ++ } ++ this._activeDecoder = decoder; } -@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos + + if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) { +@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -255,7 +490,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const cropX = Math.max(0, cmd.x ?? 0); const cropY = Math.max(0, cmd.y ?? 0); const cropW = cmd.sourceWidth || (bitmap.width - cropX); -@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } } @@ -263,7 +498,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const zIndex = cmd.zIndex ?? 0; this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); bitmap = undefined; // ownership transferred to storage -@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (image.format === KittyFormat.PNG) { @@ -276,7 +511,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const blob = new Blob([bytes as BlobPart], { type: 'image/png' }); if (!window.createImageBitmap) { const url = URL.createObjectURL(blob); -@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private async _decompressZlib(compressed: Uint8Array): Promise { try { return await this._decompress(compressed, 'deflate'); diff --git a/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch b/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch index 2c5aa15fac1..3b581c30477 100644 --- a/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch +++ b/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch @@ -1,8 +1,21 @@ diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts -index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644 +index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644 --- a/src/IIPHandler.ts +++ b/src/IIPHandler.ts -@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = { +@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; + + // Local const enum mirror - esbuild can't inline const enums from external packages + const enum DecoderConst { +- // Limit held memory in base64 decoder (encoded bytes). +- KEEP_DATA = 4194304, ++ // Held memory in base64/QOI decoders between images. Zero because each kept ++ // decoder pins a wasm memory, and V8 caps those per process (~124 in a ++ // sandboxed renderer), so idle terminals must not hold one. ++ KEEP_DATA = 0, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) +@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = { export class IIPHandler implements IOscHandler, IResetHandler { @@ -10,7 +23,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; -@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } public reset(): void { @@ -18,7 +31,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._hp.reset(); this._dec.release(); this._qoiDec.release(); -@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._aborted = true; + return; + } +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._aborted = true; ++ return; ++ } + } else if (this._abortMulti) { + this._aborted = true; + return; +@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._isMultipart = true; + this._abortMulti = false; + this._dec.release(); +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._abortMulti = true; ++ } + return true; + } + +@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { + + let blob: Blob | ImageData; + if (metrics.mime === 'image/qoi') { +- const data = this._qoiDec.decode(this._dec.data8); ++ let data: Uint8Array; ++ try { ++ data = this._qoiDec.decode(this._dec.data8); ++ } catch (e) { ++ console.warn('IIP: could not decode QOI image', e); ++ this._dec.release(); ++ this._qoiDec.release(); ++ return true; ++ } + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, +@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); @@ -32,6 +85,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._storage.addImage(bm); return true; }) +@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler { + }); + } + ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ private _initDecoder(): boolean { ++ try { ++ this._dec.init(); ++ return true; ++ } catch (e) { ++ console.warn('IIP: could not allocate decoder', e); ++ this._dec.release(); ++ return false; ++ } ++ } ++ + private _resize(w: number, h: number): [number, number] { + const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; + const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644 --- a/src/ImageAddon.ts @@ -144,8 +216,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33 } public get document(): Document | undefined { +diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts +index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644 +--- a/src/SixelHandler.ts ++++ b/src/SixelHandler.ts +@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types'; + import { ImageRenderer } from './ImageRenderer'; + + import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; ++import { LIMITS } from 'sixel/lib/wasm'; + + // always free decoder ressources after decoding if it exceeds this limit + const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB +@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + const DEFAULT_PALETTE = PALETTE_ANSI_256; + DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); + ++// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories ++// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only ++// while a SIXEL sequence is open, so idle terminals hold none. ++const MAX_IDLE_DECODERS = 2; ++const idleDecoders = new Map(); ++let poolPrimed = false; ++ ++function primeDecoderPool(memoryLimit: number): void { ++ if (poolPrimed) return; ++ poolPrimed = true; ++ // Async compile once, off the parser's hot path; later decoders reuse the cached module. ++ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then( ++ d => releaseDecoder(d, memoryLimit), ++ () => { poolPrimed = false; } ++ ); ++} ++ ++function acquireDecoder(memoryLimit: number): Decoder { ++ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE }); ++} ++ ++function releaseDecoder(dec: Decoder, memoryLimit: number): void { ++ if (dec.memoryUsage > MEM_PERMA_LIMIT) { ++ dec.release(); ++ } ++ const idle = idleDecoders.get(memoryLimit) ?? []; ++ if (idle.length < MAX_IDLE_DECODERS) { ++ idle.push(dec); ++ idleDecoders.set(memoryLimit, idle); ++ } ++} ++ + + export class SixelHandler implements IDcsHandler, IResetHandler { + private _size = 0; + private _aborted = false; + private _dec: Decoder | undefined; ++ private _decMemoryLimit = 0; ++ // Color registers outlive a single image, so they live here rather than in a pooled decoder. ++ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE); + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: SixelImageStorage, + private readonly _coreTerminal: ITerminalExt + ) { +- DecoderAsync({ +- memoryLimit: this._opts.pixelLimit * 4, +- palette: DEFAULT_PALETTE, +- paletteLimit: this._opts.sixelPaletteLimit +- }).then(d => this._dec = d); ++ this._palette.set(DEFAULT_PALETTE); ++ primeDecoderPool(this._opts.pixelLimit * 4); + } + + public reset(): void { +- /** +- * reset sixel decoder to defaults: +- * - release all memory +- * - nullify palette (4096) +- * - apply default palette (256) +- */ +- if (this._dec) { +- this._dec.release(); +- // FIXME: missing interface on decoder to nullify full palette +- (this._dec as any)._palette.fill(0); +- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); +- } ++ this._returnDecoder(); ++ this._palette.fill(0); ++ this._palette.set(DEFAULT_PALETTE); + } + + public hook(params: IParams): void { + this._size = 0; + this._aborted = false; +- if (this._dec) { +- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( +- this._coreTerminal._core._inputHandler._curAttrData, +- this._coreTerminal._core._themeService?.colors); +- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); ++ this._returnDecoder(); ++ const memoryLimit = this._opts.pixelLimit * 4; ++ try { ++ this._dec = acquireDecoder(memoryLimit); ++ } catch (e) { ++ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn(`SIXEL: could not allocate decoder - ${e}`); ++ this._aborted = true; ++ return; + } ++ this._decMemoryLimit = memoryLimit; ++ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( ++ this._coreTerminal._core._inputHandler._curAttrData, ++ this._coreTerminal._core._themeService?.colors); ++ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit); ++ } ++ ++ private _returnDecoder(): void { ++ const dec = this._dec; ++ if (!dec) return; ++ this._dec = undefined; ++ this._palette.set(dec.palette); ++ releaseDecoder(dec, this._decMemoryLimit); + } + + public put(data: Uint32Array, start: number, end: number): void { +@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + } + + public unhook(success: boolean): boolean | Promise { ++ try { ++ return this._unhook(success); ++ } finally { ++ this._returnDecoder(); ++ } ++ } ++ ++ private _unhook(success: boolean): boolean { + if (this._aborted || !success || !this._dec) { + return true; + } +@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + + const canvas = ImageRenderer.createCanvas(undefined, width, height); + canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0); +- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { +- this._dec.release(); +- } + this._storage.addImage(canvas); + return true; + } diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts -index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644 +index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644 --- a/src/kitty/KittyGraphicsHandler.ts +++ b/src/kitty/KittyGraphicsHandler.ts @@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm'; @@ -172,10 +392,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f this._cleanupAllPending(); if (this._activeDecoder) { this._activeDecoder.release(); -@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { +- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); +- this._activeDecoder.init(); + // Budget WASM capacity, including one page of decoder state and rounding. + const decoderCapacity = this._maxEncodedBytes + 131072; + if (decoderCapacity > this._opts.storageLimit * 1000000) { @@ -195,10 +417,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f + this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0); + } + } - this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); - this._activeDecoder.init(); ++ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); ++ try { ++ decoder.init(); ++ } catch (e) { ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn('KITTY: could not allocate decoder', e); ++ this._aborted = true; ++ if (this._parsedCommand?.id !== undefined) { ++ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0); ++ } ++ return; ++ } ++ this._activeDecoder = decoder; } -@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos + + if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) { +@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -210,7 +445,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const cropX = Math.max(0, cmd.x ?? 0); const cropY = Math.max(0, cmd.y ?? 0); const cropW = cmd.sourceWidth || (bitmap.width - cropX); -@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } } @@ -218,7 +453,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const zIndex = cmd.zIndex ?? 0; this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); bitmap = undefined; // ownership transferred to storage -@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (image.format === KittyFormat.PNG) { @@ -231,7 +466,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const blob = new Blob([bytes as BlobPart], { type: 'image/png' }); if (!window.createImageBitmap) { const url = URL.createObjectURL(blob); -@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private async _decompressZlib(compressed: Uint8Array): Promise { try { return await this._decompress(compressed, 'deflate'); diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4e4443354fa..2c974d9bf59 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -340,114 +340,145 @@ "demotionRule": "Keep experimental or demote if output differs, discarded bytes return, settled capture remains reachable, cancellation loses its tail, or the deterministic encoding budget regresses. Never weaken the oracle or increase retries to obtain a pass." }, { - "id": "agent-session.trust-preflight-completion", - "title": "Agent launch preflight waits for its trust owner to settle", + "id": "agent-session.spawn-workspace-trust", + "title": "Agent PTY spawns wait for a bounded workspace trust write", "maturity": "experimental", "protection": "partial", "owner": "agent-session-runtime", "layer": "cross-layer-unit", "surfaces": [ - "agent trust IPC", - "remote (SSH) agent workspace trust writer", - "renderer agent preflight", - "Codex continuation launch admission", - "main-process worktree startup trust", - "Codex launch and resume preparation" + "renderer PTY spawn builder", + "runtime PTY spawn builder", + "structured Codex chat create intent", + "agent workspace trust dispatcher", + "execution-host workspace trust writer (main and SSH relay)", + "SSH relay agent workspace trust" ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Actual main IPC handler, renderer preflight, session-continuation caller and config mutation queue with fake Electron bridge, config writer, launch and remote owner, plus the actual main-process worktree-startup, Codex quick-launch-prep and Codex resume-prep modules with mocked trust writer, runtime home and hook service. Counts prove admission ordering and the per-call trust-write bound only; no native PTY/CLI, actual config mutation or live provider evidence. Because codexHookService and ensureRealHomeCodexHookState are both mocked and isHostSystemDefaultRealHomeSelected defaults to false, no suite drives either config.toml lane the two launch-prep sites re-enter after the trust write, so nothing here speaks to whether a launch as a whole is bounded. The remote-preset suite calls markRemoteAgentWorkspaceTrusted directly and asserts its artifact shapes plus its unresolvable-home bail-out; nothing exercises markRemoteWorktreeTrusted, the unbounded caller that wraps it on the launch paths, so the SSH side of the bound is unmeasured here.", - "motivatingLinks": ["https://github.com/stablyai/orca/blob/main/src/main/ipc/agent-trust.ts"], - "invariant": "Every local Codex trust write a launch waits on - the five markCodexProjectTrusted call sites - settles before that launch proceeds, or is abandoned at a bounded deadline; rejected, abandoned and SSH-unreachable writes leave the workspace untrusted and never fall back to local trust. Two limits sit outside that claim and are not asserted. First, the deadline bounds the write, not the whole launch: a caller whose next step re-enters the same config.toml lane can still wait on that lane after its own write is abandoned. Second, one Codex trust write a launch waits on is not bounded at all - the remote write reached through markRemoteWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts, which derives preset 'codex' from TUI_AGENT_CONFIG the same way the local path does and is awaited ahead of the agent spawn on every SSH launch path. Only the agentTrust:markTrusted handler's remote write is capped.", - "oracle": "Held local write or actual queue predecessor yields zero continuation launch calls until release, then exactly one; delayed local rejection settles successfully afterward. SSH awaits its remote writer and never invokes local trust. A never-settling local or SSH write resolves the handler at the deadline with a named warning, no leaked timer and no local fallback, and a rejection arriving after the deadline stays handled. An already-complete write admits the launch on microtasks with zero timers. Two concurrent launches for one workspace settle independently. In the main-process worktree-startup, Codex quick-launch-prep and Codex resume-prep modules a never-settling write resolves the caller at the deadline, still yields the launch home or resume outcome, leaks no timer, and writes no other preset artifact; the write stays ordered before the runtime-home and hook-repair steps the spawn waits on. Those three module cases mock codexHookService, so they establish that the write is abandoned at the deadline, not that the launch around it is bounded. No case drives markRemoteWorktreeTrusted, so nothing here bounds the remote Codex write that fronts an SSH launch; the remote-preset suite checks only what markRemoteAgentWorkspaceTrusted writes and when it bails out, never how long it may take.", + "coverageNotes": "The actual renderer and runtime spawn-option builders with a mocked dispatcher, the spawn hook with a mocked dispatcher, the structured Codex create intent with a mocked dispatcher, the dispatcher with mocked preset writers and the real Claude config writer on a temp file, the execution-host writer and the relay spawn function with every real preset writer under a throwaway home, and the relay pty.spawn handler with a mocked trust function. Proves the builders await trust before returning, the fresh-launch and setting gates, the per-preset deadlines, that SSH launches hand every preset to the relay, and, for the agents that inherit trust from a home (Claude, Copilot, Qoder), the refusal of any stored path that is a root, a home or a folder above one, while Codex, Cursor and Antigravity still trust a home. No native PTY, agent CLI, live SSH host or WSL guest.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/agent-workspace-trust.ts" + ], + "invariant": "A fresh PTY launch of an agent with a trust preset pre-trusts its workspace root (for Codex, whose lookup keys on its start folder, the folder it starts in, including a floating terminal's) before the provider spawn, in both spawn builders, on the host that runs the agent: main for local and WSL launches, the SSH relay on its own disk for SSH launches. It never waits past the preset's deadline: 20 s for local Codex, the short budget for every other write, local or on the relay. A failed or abandoned write lets the launch proceed untrusted, so the agent asks. Restored or reattached panes, spawns with no launch command and the setting turned off are never trusted; for an agent whose trust on a home covers the folders below it (Claude, Copilot, Qoder), no writer stores a path that is a root, a home or a folder above one, and an unknown home writes nothing; Codex, Cursor and Antigravity, whose trust on a home covers only the home, trust it as they did before; and a WSL launch never writes the Windows home. A structured Codex chat, which has no PTY, pre-trusts its folder the same way when it is created, under the same setting.", + "oracle": "A trust write held pending keeps each builder from returning until it settles. A never-settling local Codex write resolves the dispatcher only after the long deadline; a never-settling local Claude write resolves after the short budget with a named warning. Rejected and throwing writes, and a throwing guard, resolve with a warning. Restored panes, spawns without a command and the setting off make no dispatcher call; Codex in a floating terminal or a workspace subfolder is trusted at that folder, and every other preset at the workspace root. Creating a structured Codex chat trusts its folder before launch preparation, and nothing with the setting off. An SSH launch returns the relay field for every preset and calls no writer, and the relay's pty.spawn starts no process until that launch's trust call settles. A home, a folder above one, a root, a symlink to a home and a missing path that climbs back to the home through `..` write no Claude, Copilot or Qoder trust file, on this machine and on the relay, and the home writes Codex, Cursor and Antigravity trust.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/agent-trust-completion.unit.test.ts src/renderer/src/lib/agent-trust-preflight.test.ts src/renderer/src/lib/launch-agent-session-continuation.test.ts src/main/remote-agent-trust-presets.test.ts src/main/startup/codex-launch-trust-write-deadline.test.ts src/main/runtime/runtime-worktree-agent-startup.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" ], "testFiles": [ - "tests/e2e/agent-trust-completion.unit.test.ts", - "src/renderer/src/lib/agent-trust-preflight.test.ts", - "src/renderer/src/lib/launch-agent-session-continuation.test.ts", - "src/main/remote-agent-trust-presets.test.ts", - "src/main/startup/codex-launch-trust-write-deadline.test.ts", - "src/main/runtime/runtime-worktree-agent-startup.test.ts" + "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts", + "src/main/agent-workspace-trust-spawn.test.ts", + "src/main/agent-workspace-trust.test.ts", + "src/main/execution-host-workspace-trust.test.ts", + "src/relay/agent-workspace-trust-spawn.test.ts", + "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", + "src/relay/pty-handler-agent-workspace-trust.test.ts", + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" ], "assertionRefs": [ { - "file": "tests/e2e/agent-trust-completion.unit.test.ts", + "file": "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts", "assertions": [ - "holds the actual session-continuation launch call until the local write settles", - "keeps renderer preflight pending behind the actual config mutation queue", - "settles best-effort only after a queued local trust failure", - "bounds a never-settling local write and continues untrusted", - "admits an already-complete write without waiting for any timer", - "absorbs a local rejection that arrives after the deadline", - "bounds a never-settling SSH trust write without a local fallback", - "settles two concurrent launches for the same workspace independently", - "keeps remote failures best-effort without a local fallback" + "trusts Codex in a floating terminal at the resolved folder it starts in", + "holds the spawn until the trust write settles", + "never re-runs trust for a restored pane or a spawn with no launch command" ] }, { - "file": "src/main/startup/codex-launch-trust-write-deadline.test.ts", + "file": "src/main/agent-workspace-trust-spawn.test.ts", "assertions": [ - "bounds a never-settling write in quick-launch prep and still returns the launch home", - "keeps the quick-launch trust write ahead of the home the spawn waits on", - "bounds a never-settling write in resume prep and still resolves the resume home", - "keeps the resume trust write ahead of the hook repair that precedes the spawn", - "contains a rejected resume trust write without trusting the workspace elsewhere" + "does nothing with the setting off", + "does nothing for a restored or reattached pane", + "trusts Codex in a floating terminal at the folder it starts in", + "trusts Codex at a subfolder it starts in, which its lookup keys on" ] }, { - "file": "src/main/runtime/runtime-worktree-agent-startup.test.ts", + "file": "src/main/agent-workspace-trust.test.ts", "assertions": [ - "waits for the Codex trust write before resolving", - "bounds a never-settling Codex trust write instead of holding the launch open" + "contains a rejected or throwing write so the launch proceeds", + "gives only Codex the long deadline its shared config lane needs", + "gives a local Claude write a short budget, after which Claude asks", + "never pre-trusts %s for an agent that inherits trust from it", + "trusts the home folder for Codex, Cursor and Antigravity, whose trust there stays there", + "hands an SSH %s launch to the relay instead of writing anything here" + ] + }, + { + "file": "src/main/execution-host-workspace-trust.test.ts", + "assertions": [ + "writes no %s trust", + "guards exactly the agents that inherit trust from a home", + "trusts a home folder workspace for %s, whose trust there covers only the home", + "writes no %s trust when the host knows no home" + ] + }, + { + "file": "src/relay/agent-workspace-trust-spawn.test.ts", + "assertions": [ + "writes %s trust on the relay host's own disk", + "trusts the relay home for %s, whose trust there covers only the home", + "leaves Antigravity to ask, since its writer is unverified on SSH hosts" + ] + }, + { + "file": "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", + "assertions": ["skips %s trust and never fails the spawn"] + }, + { + "file": "src/relay/pty-handler-agent-workspace-trust.test.ts", + "assertions": [ + "writes the launch's trust with its final env before the agent's process starts" + ] + }, + { + "file": "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", + "assertions": [ + "pre-trusts the chat folder before launch preparation, as a Codex terminal launch does", + "writes nothing with the setting off, and still prepares the launch" ] } ], "evidenceRuns": [ { - "date": "2026-09-27", + "date": "2026-09-29", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/agent-trust-completion.unit.test.ts src/renderer/src/lib/agent-trust-preflight.test.ts src/renderer/src/lib/launch-agent-session-continuation.test.ts src/main/remote-agent-trust-presets.test.ts src/main/startup/codex-launch-trust-write-deadline.test.ts src/main/runtime/runtime-worktree-agent-startup.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", "result": "passed", - "durationSeconds": 0.413, - "summary": "53 tests across six suites pass: 15 trust-completion (four deadline cases), 8 renderer preflight, 7 continuation-launch, 6 remote-preset, 5 Codex launch/resume prep deadline and 12 worktree-startup tests. Six of those are the bound and ordering cases this candidate adds. No native launch, PTY or actual config write." + "durationSeconds": 4.16, + "summary": "201 tests across eight suites pass, run through node_modules/.bin/vitest with a throwaway HOME. No native launch, PTY or live SSH host." } ], "runtimeBudget": { "p95Seconds": 10, - "scope": "Focused mocked preflight/continuation/remote trust suites; p95 not established." + "scope": "Focused spawn-builder, hook, dispatcher, execution-host writer and relay suites; p95 not established." }, "flakeHistory": { "status": "not-started", - "evidence": "Author and independent local mocked evidence; CI soak and native platform evidence pending." + "evidence": "Local mocked evidence only; CI soak pending." }, "redGreenEvidence": { "status": "complete", - "evidence": "Trust-completion suite, re-run at the earlier head. Pre-await implementation: 5 fail/10 pass - the three launch-admission orderings plus the never-settling local and SSH cases, the SSH one only by exhausting the test timeout. Unbounded-await implementation: 3 fail/12 pass - never-settling local write, post-deadline rejection and never-settling SSH write all hang to the test timeout. The already-complete-write case passes unbounded too, so it is a timer control, not a red. Launch-prep and worktree-startup suites, this head: with the three call sites reverted to a bare await, 3 fail/14 pass - the quick-launch, resume and worktree-startup never-settling cases each hang to the 30s vitest timeout; restored, 17 pass. Whole gate at this head: 53 pass across six suites." + "evidence": "Each named case failed with its fix removed and passed restored: the builder's await turned into a fire-and-forget call fails the hold case; guarding every preset fails each Codex, Cursor and Antigravity home case, and guarding none fails each Claude, Copilot and Qoder home, root, symlink and `..` case; dropping the unknown-home rule fails every no-home case; the relay deriving only Claude from launchAgent fails each non-Claude relay case; the dispatcher handing only Claude to the relay fails each SSH preset case; dropping the relay's Antigravity return fails its case; letting the host writer's catch rethrow fails every guard-failure case; dropping resolve() from the guard's forms fails the missing `..` path case for every guarded preset; marking Codex as trusted at the workspace root, or a builder dropping the start folder, fails the Codex floating-terminal and subfolder cases; removing the relay handler's trust call, or not awaiting it, fails the relay handler case; removing the structured Codex create intent's trust call fails its pre-trust case, and dropping its setting check fails its setting-off case." }, "performanceBudget": { "required": true, - "evidence": "No new subprocess, polling, retry or trust operation. Each of the five markCodexProjectTrusted call sites a launch awaits is capped by one per-call deadline timer, cleared on settlement, so none of those five can hold its caller indefinitely; the five share that one timer shape and no site gained a second write or a retry. The cap covers the local Codex writes only - the remote write through markRemoteWorktreeTrusted is still awaited with no timer, so an SSH launch can still be held open by a trust write. It also bounds the trust write, not the launch. At startup/codex-launch-preparation.ts the first awaited step after the write is ensureRealHomeHooksIfSelected, not codexHookService.prepareRuntimeHomeForLaunch: when the host system-default real home is selected it calls ensureRealHomeCodexHookState, which acquires runExclusivelyForCodexTrustConfig directly on ~/.codex/config.toml, the same lane the abandoned write still holds a queue slot for. When the real home is not selected that call returns without awaiting the lane and runtimeHome.prepareForCodexLaunchAsync is synchronous on this non-WSL path, so the first re-entry is prepareRuntimeHomeForLaunch through runExclusivelyForRuntimeAndSystemTrustConfig. At startup/codex-session-resume-launch.ts the first awaited step is the hook branch: ensureRealHomeCodexHookState when the resume home is ~/.codex, otherwise installForLaunchPrep or refreshRuntimeUserHooksForLaunchPrep through runExclusivelyForRuntimeAndSystemTrustConfig, whose inner acquire is that same ~/.codex/config.toml. Either way a wedged lane still stalls those two launches one step after the write is abandoned, and the earliest such step is the real-home ensure, not the hook-service call the gate used to name. Only markLocalWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts has nothing after its Codex branch, so only it has an end-to-end bounded launch path. Reliability fix, no performance gain claim." + "evidence": "Plain shells and agents without a preset add no trust I/O; the hook returns after the preset check. A local Codex write is capped at 20 s and every other write, local or on the relay, at the short budget, each by one timer cleared on settlement. An SSH launch adds no round trip: the field rides the existing pty.spawn request. The deadline bounds the wait, not the write: an abandoned write still lands later. The local Claude writer reads and parses Claude's config synchronously on the main thread before its lock." }, "knownGaps": [ "CI soak pending.", - "No live Codex CLI, PTY, trust prompt, native Electron app or actual trust-config mutation was exercised.", - "Windows/Linux/WSL/daemon/remote-runtime behavior was not executed; remote owner is mocked. No routing/wire/PTY ownership change.", - "Abandoning a write at the deadline does not cancel it; a legitimate slow write (WSL grant sessions run to 30s) can land after the launch already started, restoring the pre-fix ordering for that case only.", - "The deadline value is a reasoned cap over observed lane holders, not a measured p95.", - "All five awaited local Codex trust writes (markCodexProjectTrusted) are now bounded by the same helper: the agentTrust:markTrusted IPC handler, ipc/worktree-remote.ts, runtime/runtime-worktree-agent-startup.ts, startup/codex-launch-preparation.ts and startup/codex-session-resume-launch.ts. Four have a test in these suites; the ipc/worktree-remote.ts site is bounded but still has no test here. A sixth awaited Codex trust write - the remote one through markRemoteWorktreeTrusted - is not bounded at all; see the remote gap below.", - "Bounding the trust write does not bound the launch at two of the three new sites, and on both the first unbounded re-entry is earlier than the hook-service call. In startup/codex-launch-preparation.ts the next awaited call after the abandoned write is ensureRealHomeHooksIfSelected, which is conditional: only when the target is not WSL and runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv) is true does it call ensureRealHomeCodexHookState, which chains behind that module's own serial ensure promise and then acquires runExclusivelyForCodexTrustConfig on ~/.codex/config.toml (it skips the lane only when hooks are enabled, the lane is already unavailable and its retry backoff has not elapsed). When the real home is not selected that call returns without awaiting the lane, runtimeHome.prepareForCodexLaunchAsync is synchronous on the non-WSL path, and the first re-entry is codexHookService.prepareRuntimeHomeForLaunch. In startup/codex-session-resume-launch.ts the next awaited call is the hook branch: ensureRealHomeCodexHookState when the resume home is ~/.codex, otherwise installForLaunchPrep or refreshRuntimeUserHooksForLaunchPrep. All of those reach the same unbounded per-config.toml serial queue (runExclusivelyForCodexTrustConfig, FIFO with no depth cap and no timeout) on ~/.codex/config.toml; the runExclusivelyForRuntimeAndSystemTrustConfig wrapper takes the runtime home's config.toml first, and markCodexProjectTrusted itself takes both. A wedged lane therefore still stalls a quick-launch or a resume one step after the write is abandoned, and the abandoned write keeps its queue slot ahead of that step. The new launch-prep tests mock codexHookService and ensureRealHomeCodexHookState wholesale and default the real-home selection to false, so no test in these suites can catch this residual stall. Only markLocalWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts, which has nothing after its Codex branch, is bounded end to end.", - "The cap remains per call site, not global. Two launches racing the same wedged lane each wait their own deadline, so the worst case a user can see is one deadline per launch, not one shared cap.", - "The remote (SSH) trust write reached through markRemoteWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts is still awaited unbounded; only the agentTrust:markTrusted handler's remote write is capped. It is a Codex trust write a launch waits on, not merely an adjacent one: it reads TUI_AGENT_CONFIG[agent].preflightTrust, which is 'codex' for the codex agent, and markRemoteAgentWorkspaceTrusted then branches into markRemoteCodexProjectTrusted. Its only production caller is markRemoteWorkspaceTrustedForAgent in runtime/orca-runtime-activate-managed-worktree.ts, reached with a connectionId from seven launch entry points. On orca-runtime-create-agent-session.ts, orca-runtime-get-agent-session-execution-namespace.ts, orca-runtime-terminal-create-deduplication.ts, runtime-remote-managed-worktree-create.ts and runtime-folder-worktree-create.ts (wired in by orca-runtime-create-managed-worktree.ts) the await sits directly before the createTerminal that spawns the agent; on orca-runtime-resolve-mobile-session-terminal-command.ts and orca-runtime-resolve-worktree-removal-target.ts it instead gates the launch command those functions return for their caller to spawn, so the launch still waits on it. It chains a session.resolveHome multiplexer round trip, an SFTP realpath, a read, a createDir and a write, none with a timeout, over a link that may be half-open - so an SSH Codex launch can still hang with nothing happening, the same failure this branch bounds locally. Deliberately left out of scope here, and deliberately left out of the invariant." + "No live agent CLI, PTY, trust prompt, native Electron app, SSH host or WSL guest was exercised.", + "Abandoning a write at the deadline does not cancel it; a slow write can land after the agent already asked.", + "The deadline values are reasoned caps, not measured p95s.", + "Antigravity over SSH is not written on the relay (its writer is unverified there), so it still asks." ], "promotionCriteria": [ - "Retain pending-write/queue/launch-boundary red-green evidence and remote owner assertions; obtain actual platform/session evidence and CI soak before promotion." + "Retain the hold, deadline and home or root red/green evidence; obtain native platform and SSH evidence and a CI soak before promotion." ], - "demotionRule": "Remain experimental until repeated independent/CI evidence; investigate ordering or unbounded-wait regressions without weakening completion or deadline assertions." + "demotionRule": "Remain experimental until repeated independent or CI evidence; investigate ordering or unbounded-wait regressions without weakening the hold or deadline assertions." }, { "id": "settings.general-section-lifetime", @@ -461,20 +492,9 @@ "app version and remote update display", "autosave settings draft" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "remote-runtime", - "ssh", - "wsl" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime", "ssh", "wsl"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Actual GeneralPane, update/version and remote-status components with mocked store/API; actual autosave form. Windows runtime on/off simulated. No physical updater, paired host, settings write or native input.", "motivatingLinks": [ @@ -485,9 +505,7 @@ "commands": [ "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx" ], - "testFiles": [ - "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx" - ], + "testFiles": ["src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx"], "assertionRefs": [ { "file": "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx", @@ -551,20 +569,9 @@ "Grok and Cursor account status", "Codex pending sign-in link" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "remote-runtime", - "ssh", - "wsl" - ], - "coveredPlatforms": [ - "macos" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime", "ssh", "wsl"], + "coveredPlatforms": ["macos"], "coveredProviders": [], "coverageNotes": "Actual AccountsPane, Grok, Cursor and Codex login components; synthetic APIs/store with Windows account support enabled/disabled. No real account, keychain or provider action.", "motivatingLinks": [ @@ -575,9 +582,7 @@ "commands": [ "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx" ], - "testFiles": [ - "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx" - ], + "testFiles": ["src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx"], "assertionRefs": [ { "file": "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx", @@ -943,7 +948,7 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-session-operation-retention.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ @@ -957,7 +962,6 @@ "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts", "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", - "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx", "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", @@ -1033,7 +1037,7 @@ "date": "2026-09-12", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", "durationSeconds": 22.1, "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." @@ -12353,7 +12357,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/main/ssh/relay-protocol-backpressure.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts src/main/ssh/ssh-relay-session-model-migration.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", @@ -12400,7 +12404,6 @@ "src/main/ssh/ssh-relay-session-terminal-error.test.ts", "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", - "src/main/ssh/relay-protocol-backpressure.test.ts", "src/relay/protocol-backpressure.test.ts", "src/relay/git-response-stream-ownership.test.ts", "src/relay/pty-handler-output-streaming.test.ts", @@ -12617,13 +12620,6 @@ "invalid-checkpoint cancellation retains the exact delivery until restore response settlement, then retry mints a fresh activation and emits one live source frame" ] }, - { - "file": "src/main/ssh/relay-protocol-backpressure.test.ts", - "assertions": [ - "main SSH decoder accepts one maximum frame plus 1 MiB partial input and rejects one extra byte", - "a throwing continuation clears retained input, releases one pause epoch, and publishes one typed ownership error" - ] - }, { "file": "src/relay/protocol-backpressure.test.ts", "assertions": [ @@ -12812,7 +12808,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/main/ssh/relay-protocol-backpressure.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "result": "passed", "durationSeconds": 2.24, "summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised." @@ -13158,7 +13154,7 @@ "invariant": "PTY snapshot capability discovery must never synchronously block renderer JavaScript. Restored daemon capability must be known before workspace readiness enables cold activation; unknown or legacy capability must remain eager. A healthy SSH provider must return definitive false without polling. One unresponsive capability batch must fail open within one second regardless of PTY count, and stale async responses must not update current bindings.", "oracle": "The preload test rejects sendSync and requires ipcRenderer.invoke. Unit contracts assert 512-ID batching, one-second fail-open, unknown retry, definitive SSH false, and generation-fenced stale responses. During an injected 1.5-second Electron main-thread stall, a renderer-owned 50ms interval must keep a maximum gap below 500ms and each API call must return within 100ms. The production cold-activation journey must still mount at most three of eight daemon tabs after reload, while Docker SSH restoration remains eager.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/app-startup-routing.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -13172,7 +13168,6 @@ "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx", "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts", - "src/renderer/src/app-startup-routing.test.ts", "tests/e2e/pty-snapshot-capability-main-stall.spec.ts", "tests/e2e/terminal-cold-activation-deferral.spec.ts", "tests/e2e/ssh-cold-activation-restore.spec.ts" @@ -15501,7 +15496,7 @@ "oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then distinguish the three settlement outcomes end to end: only a proven refusal releases the reservation and drains a delivery parked behind the watermark; a dropped in-flight settlement must surface as unverifiable with bytes handed to the transport, preserve the durable write-attempted reservation, and emit no duplicate pointer after restart; a settled write that throws mid-pointer is unverifiable, not a refusal; and an Enter whose settlement is lost stays at enter-attempted so restart emits no second Enter. Install the production PTY controller and verify that it routes settled writes through the owning provider and refuses before any byte when the routed provider cannot settle. Census every production PTY provider class and reject a settlement synthesized from the fire-and-forget write.", "commands": [ "pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot" ], @@ -15518,7 +15513,6 @@ "src/main/runtime/orchestration/formatter.test.ts", "src/main/providers/ssh-pty-provider.test.ts", "src/main/providers/ssh-pty-write.test.ts", - "src/main/providers/settled-pty-writer-census.test.ts", "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "src/main/daemon/client.test.ts", "src/main/daemon/daemon-pty-router.test.ts", @@ -15620,13 +15614,6 @@ "file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "assertions": ["a refused pointer write drains a delivery parked behind its watermark"] }, - { - "file": "src/main/providers/settled-pty-writer-census.test.ts", - "assertions": [ - "every production IPtyProvider class exposes a settled writer", - "no settled writer synthesizes its settlement from the fire-and-forget write" - ] - }, { "file": "src/main/ipc/pty-controller-ownership-routing.test.ts", "assertions": [ @@ -15666,7 +15653,7 @@ "date": "2026-09-05", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "result": "passed", "durationSeconds": 4.73, "summary": "267 tests passed after the pointer-write path moved to the three-valued WriteSettlement union. New coverage: a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport, a settled write that throws mid-pointer preserves the write-attempted reservation, an Enter whose settlement is lost stays at enter-attempted with no second Enter after restart, a refusal releases the reservation and drains a delivery parked behind its watermark, the production controller refuses before any byte when the routed provider cannot settle, and a census pins the five production IPtyProvider classes and rejects a settlement synthesized from the fire-and-forget write. Each new assertion was verified red against the pre-fix shape." @@ -15675,7 +15662,7 @@ "date": "2026-08-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "result": "passed", "durationSeconds": 8.22, "summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup." diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs index ef21fed5e34..15258cea96d 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -119,15 +119,13 @@ describe('the actual Bun build and profile-test dependency graph', () => { expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(true) }) - it('retains all selected tests and uses the same selectors as the Bun runner', () => { + it('retains all selected tests and the selectors the Bun runner uses', () => { const tests = discoverBunProfileTests() expect(tests.length).toBeGreaterThan(80) expect(tests.every((file) => inputs.has(file))).toBe(true) expect( bunProfileTestPaths().every((selector) => tests.some((file) => file.includes(selector))) ).toBe(true) - const runner = readFileSync(new URL('./run-bun-profile-tests.mjs', import.meta.url), 'utf8') - expect(runner).toContain('testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact })') }) }) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 664e8268662..1e855befdc5 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -117,7 +117,6 @@ "config/scripts/mobile-launch-contract-workflow.test.mjs": 439, "config/scripts/mobile-mirrored-storage-write-path.test.mjs": 136, "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 421, - "config/scripts/mobile-recording-pin-checkout.test.mjs": 309, "config/scripts/mobile-release-check-scope.test.mjs": 610, "config/scripts/mobile-release-shell-switch-workflow.test.mjs": 227, "config/scripts/mobile-set-native-props-web-siblings.test.mjs": 959, @@ -193,7 +192,6 @@ "config/scripts/package-electron-runtime-contract.test.mjs": 447, "config/scripts/package-linux-formats.test.mjs": 208, "config/scripts/packaged-browser-lane-contract.test.mjs": 104, - "config/scripts/packaged-hang-watchdog-worker-contract.test.mjs": 161, "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 93, "config/scripts/packaged-source-map-prune.test.mjs": 117, "config/scripts/patched-dependencies-frozen-install.test.mjs": 900, @@ -1301,7 +1299,6 @@ "src/main/codex/codex-structured-session-shutdown.test.ts": 471, "src/main/codex/codex-structured-thread-goal.test.ts": 73, "src/main/codex/codex-structured-thread-open.test.ts": 300, - "src/main/codex/codex-structured-turn-processes.integration.test.ts": 194, "src/main/codex/codex-subagent-execution-projection.test.ts": 202, "src/main/codex/codex-subagent-executions.test.ts": 71, "src/main/codex/codex-subagent-roster.test.ts": 118, @@ -1610,7 +1607,6 @@ "src/main/daemon/terminal-host-teardown-recreate.test.ts": 484, "src/main/daemon/terminal-host-wsl-context.test.ts": 249, "src/main/daemon/terminal-host.test.ts": 691, - "src/main/daemon/terminal-mouse-tail-retention.test.ts": 171, "src/main/daemon/terminal-session-teardown.test.ts": 95, "src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts": 215, "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 56, diff --git a/config/scripts/ci-unit-selection.test.mjs b/config/scripts/ci-unit-selection.test.mjs index 69370a675f6..936b8874f65 100644 --- a/config/scripts/ci-unit-selection.test.mjs +++ b/config/scripts/ci-unit-selection.test.mjs @@ -92,7 +92,6 @@ describe('conservative unit selection', () => { count: FULL_SHARD_COUNT })) ) - expect(FULL_SHARD_COUNT).toBe(5) }) it('records failures that would have been missed while shadow runs remain full', () => { diff --git a/config/scripts/cli-main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts deleted file mode 100644 index b545579facf..00000000000 --- a/config/scripts/cli-main-module-bundle-parity.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join, matchesGlob, resolve } from 'node:path' -import { createRequire } from 'node:module' -import { describe, expect, it } from 'vitest' -import { electronViteConfig } from '../../electron.vite.config' -import { GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard' - -const REPO_ROOT = resolve(__dirname, '..', '..') -const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') -const packaging = createRequire(import.meta.url)('../electron-builder.config.cjs') - -function listCliSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - return listCliSourceFiles(path) - } - return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - ? [path] - : [] - }) -} - -// Why: `import type` is erased by tsc, so it needs no emitted module at runtime. -const VALUE_IMPORT_FROM_MAIN = /(? { - const source = readFileSync(file, 'utf-8') - return [ - ...source.matchAll(VALUE_IMPORT_FROM_MAIN), - ...source.matchAll(DYNAMIC_IMPORT_FROM_MAIN) - ].map((match) => ({ - file: file.slice(REPO_ROOT.length + 1), - module: match[1].replace(/\.js$/, '') - })) - }) -} - -function findElectronViteMainEntries(): Record { - const input = electronViteConfig.main?.build?.rollupOptions?.input - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new Error('Expected named main-process inputs') - } - return input -} - -describe('CLI imports of main-process modules', () => { - // Why: electron-vite cleans out/main and emits only its declared entries, so a - // `src/main/*` module the CLI imports but the config omits is deleted by the - // build that runs after `build:cli` — keep source-level feedback ahead of the - // final-artifact runtime verifier. - it('has an electron-vite entry for every main module the CLI imports', () => { - const entries = findElectronViteMainEntries() - const missing = findMainImports().filter( - ({ module }) => entries[module] !== join(REPO_ROOT, 'src', 'main', `${module}.ts`) - ) - - expect(missing).toEqual([]) - }) - - it('guards every CLI main module against Electron imports', () => { - const guarded = new Set(GUARDED_ENTRY_NAMES) - expect(findMainImports().filter(({ module }) => !guarded.has(module))).toEqual([]) - }) - - it('unpacks every CLI main entry for plain Node outside Electron', () => { - const missing = findMainImports().filter( - ({ module }) => - !packaging.asarUnpack.some((pattern: string) => - matchesGlob(`out/main/${module}.js`, pattern) - ) - ) - expect(missing).toEqual([]) - }) - - it('finds the imports it is meant to guard', () => { - // Why: a broken matcher would make the guard above vacuously pass. - expect(findMainImports()).toContainEqual({ - file: join('src', 'cli', 'profile-state-location.ts'), - module: 'persistence/profile-state/profile-state-active-location' - }) - expect(findMainImports()).toContainEqual({ - file: join('src', 'cli', 'handlers', 'agent-hooks.ts'), - module: 'orca-profiles/profile-index-store' - }) - expect(findMainImports().length).toBeGreaterThanOrEqual(2) - expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2) - }) -}) diff --git a/config/scripts/client-hosted-browser-package-coverage.test.mjs b/config/scripts/client-hosted-browser-package-coverage.test.mjs index f7f1ceb5995..204cf162e55 100644 --- a/config/scripts/client-hosted-browser-package-coverage.test.mjs +++ b/config/scripts/client-hosted-browser-package-coverage.test.mjs @@ -19,13 +19,6 @@ describe('client-hosted browser package coverage', () => { expect(steps[boundaries].run).not.toContain(file) }) - it('bundles the WSL browser-network relay with its version stamp', () => { - const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') - - expect(relayBuild).toContain("outfile: join(outDir, 'wsl-browser-network-relay.js')") - expect(relayBuild).toContain("join(outDir, '.browser-network-version')") - }) - it('runs client-hosted Electron lifecycle coverage on native package hosts', () => { const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') const parsedWorkflow = parse(prWorkflow) diff --git a/config/scripts/codex-index-heal-contract-workflow.test.mjs b/config/scripts/codex-index-heal-contract-workflow.test.mjs index 62c9787a12e..505151ae5af 100644 --- a/config/scripts/codex-index-heal-contract-workflow.test.mjs +++ b/config/scripts/codex-index-heal-contract-workflow.test.mjs @@ -34,4 +34,34 @@ describe('Codex index-heal contract PR gate', () => { 'set -euo pipefail' ) }) + + it('pins the --no-daemon contract to one Codex version and fails when it is missing', () => { + const install = job.steps.find((step) => step.name === 'Install pinned no-daemon Codex CLI') + const verify = job.steps.find((step) => step.name === 'Verify Codex --no-daemon contract') + + expect(job.env.CODEX_NO_DAEMON_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/) + expect(install.run).toContain('"@openai/codex@$CODEX_NO_DAEMON_CLI_VERSION"') + expect(verify.env.ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION).toBe( + '${{ env.CODEX_NO_DAEMON_CLI_VERSION }}' + ) + expect(verify.env.ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED).toBe('1') + expect(install.run).toContain('--prefix "$RUNNER_TEMP/codex-cli-no-daemon"') + expect(verify.run).toContain( + 'ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex"' + ) + expect(verify.run).toContain('src/main/pty/codex-no-daemon-binary-contract.test.ts') + }) + + it('pins the project-trust contract to the no-daemon Codex and fails when it is missing', () => { + const verify = job.steps.find((step) => step.name === 'Verify Codex project-trust contract') + + expect(verify.env.ORCA_CODEX_TRUST_CONTRACT_VERSION).toBe( + '${{ env.CODEX_NO_DAEMON_CLI_VERSION }}' + ) + expect(verify.env.ORCA_CODEX_TRUST_CONTRACT_REQUIRED).toBe('1') + expect(verify.run).toContain( + 'ORCA_CODEX_TRUST_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex"' + ) + expect(verify.run).toContain('src/main/agent-trust-presets.test.ts') + }) }) diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index fc605bac5bd..3e31ccf5a80 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -6,197 +6,6 @@ import { parse } from 'yaml' const projectDir = resolve(import.meta.dirname, '../..') describe('computer-use e2e workflow', () => { - it('cancels superseded pull request runs without cancelling scheduled runs', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - - expect(workflow.concurrency).toEqual({ - group: 'computer-e2e-${{ github.event.pull_request.number || github.ref }}', - 'cancel-in-progress': "${{ github.event_name == 'pull_request' }}" - }) - }) - - it('runs computer-use e2e files serially because they share desktop focus', () => { - const config = readFileSync(join(projectDir, 'tests/e2e/vitest.config.ts'), 'utf8') - - expect(config).toContain('fileParallelism: false') - }) - - it('guards e2e source against fragile waits and Windows Calculator drift', () => { - const driver = readFileSync(join(projectDir, 'tests/e2e/helpers/computer-driver.ts'), 'utf8') - const cliDriver = readFileSync( - join(projectDir, 'tests/e2e/helpers/computer-cli-driver.ts'), - 'utf8' - ) - const windowsStoreE2e = readFileSync( - join(projectDir, 'tests/e2e/computer-windows-store.e2e.ts'), - 'utf8' - ) - - expect(driver).not.toContain('await delay(3500)') - expect(driver).toContain("await waitForComputerWindowTitle('gedit', fileName, 15000)") - expect(cliDriver).toContain('ORCA_DEV_USER_DATA_PATH') - expect(cliDriver).toContain('orca-computer-runtime-') - expect(cliDriver).toContain('retryMissingRuntimeMetadata') - expect(cliDriver).toContain('Could not read Orca runtime metadata') - expect(cliDriver).toContain("'serve', '--no-pairing', '--json'") - - expect(windowsStoreE2e).toContain("app.bundleId === 'ApplicationFrameHost'") - expect(windowsStoreE2e).toContain("app.bundleId === 'win32calc'") - expect(windowsStoreE2e).toContain('buttonIndex >= 0') - expect(windowsStoreE2e).toContain('pane(?:\\s|$)/m') - expect(windowsStoreE2e).toContain('String(clickIndex)') - expect(windowsStoreE2e).not.toContain( - "for (const buttonName of ['One', 'Plus', 'Two', 'Equals'])" - ) - }) - - it('triggers on computer-use shared contracts, scripts, and agent skill changes', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'config/scripts/computer-e2e-workflow.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', - 'config/scripts/computer-use-modifier-safety.test.mjs', - 'config/scripts/computer-use-skill-guidance.test.mjs', - 'config/scripts/computer-use-smoke.mjs', - 'config/scripts/computer-use-smoke.test.mjs', - 'skills/computer-use/SKILL.md', - 'src/main/computer/**', - 'src/main/runtime/rpc/dispatcher.ts', - 'src/main/runtime/rpc/errors.ts', - 'src/main/runtime/rpc/methods/computer*.ts', - 'src/shared/computer-use-*.ts', - 'tests/e2e/vitest.config.ts' - ]) - ) - expect(triggerPaths).not.toContain('src/shared/runtime-types.ts') - }) - - it('runs focused computer-use regression tests in the PR native-smoke job', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const nativeSmokeRuns = workflow.jobs['native-smoke'].steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - const checkout = workflow.jobs['native-smoke'].steps.find( - (step) => step.uses === 'actions/checkout@v6' - ) - const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run')) - const expectedRegressionFiles = [ - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', - 'config/scripts/computer-use-modifier-safety.test.mjs', - 'config/scripts/computer-use-skill-guidance.test.mjs', - 'config/scripts/computer-use-smoke.test.mjs', - 'src/main/computer/computer-provider-lifecycle.test.ts', - 'src/main/computer/computer-provider-unavailable-message.test.ts', - 'src/main/computer/sidecar-client.test.ts', - 'src/main/computer/macos-native-provider-client.test.ts', - 'src/main/computer/macos-native-provider-socket.test.ts', - 'src/main/computer/macos-computer-use-permissions.test.ts', - 'src/main/computer/macos-computer-use-permission-status.test.ts', - 'src/main/computer/desktop-script-provider-client.test.ts', - 'src/main/computer/desktop-script-provider-cache.test.ts', - 'src/main/computer/desktop-script-provider-actions.test.ts', - 'src/main/computer/desktop-script-provider-cache-lifecycle.test.ts', - 'src/main/computer/desktop-script-provider-errors.test.ts', - 'src/main/computer/desktop-script-provider-action-errors.test.ts', - 'src/shared/computer-use-error-recovery.test.ts', - 'src/shared/computer-use-key-spec.test.ts', - 'src/cli/format.test.ts', - 'src/cli/handlers/computer.test.ts', - 'src/cli/handlers/computer-action-routing.test.ts', - 'src/cli/handlers/computer-action-validation.test.ts', - 'src/cli/handlers/computer-state-formatting.test.ts', - 'src/cli/specs/computer.test.ts', - 'src/cli/index.test.ts', - 'src/main/runtime/rpc/dispatcher-computer-errors.test.ts', - 'src/main/runtime/rpc/errors.test.ts', - 'src/main/runtime/rpc/methods/computer.test.ts', - 'src/main/runtime/rpc/methods/computer-actions.test.ts', - 'src/cli/runtime/envelope-schema.test.ts', - 'src/shared/remote-runtime-client.test.ts' - ] - - expect(checkout.with['persist-credentials']).toBe(false) - expect(regressionRun).toBeTruthy() - for (const file of expectedRegressionFiles) { - expect(regressionRun).toContain(file) - } - }) - - it('keeps Linux native imports available without installing the GUI-only stack in PR smoke', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const nativeSmokeInstall = workflow.jobs['native-smoke'].steps.find( - (step) => step.if === "runner.os == 'Linux'" - ) - const scheduledLinuxInstall = workflow.jobs.linux.steps.find((step) => - step.run?.includes('apt-get install') - ) - - expect(nativeSmokeInstall.run).toContain('python3') - expect(nativeSmokeInstall.run).toContain('python3-gi') - expect(nativeSmokeInstall.run).toContain('gir1.2-atspi-2.0') - expect(nativeSmokeInstall.run).toContain('at-spi2-core') - expect(nativeSmokeInstall.run).not.toContain('gedit') - expect(nativeSmokeInstall.run).not.toContain('xvfb') - expect(nativeSmokeInstall.run).not.toContain('xdotool') - expect(scheduledLinuxInstall.run).toContain('gedit') - expect(scheduledLinuxInstall.run).toContain('xvfb') - expect(scheduledLinuxInstall.run).toContain('xdotool') - }) - - it('builds and tests the macOS helper on every trigger without hosted TCC e2e', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const job = workflow.jobs['mac-native-owner-smoke'] - const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string') - const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') - const install = job.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - - expect(job.if).toBeUndefined() - expect(job['runs-on']).toBe('macos-15') - expect(checkout.with['persist-credentials']).toBe(false) - expect(install.with['native-runtime']).toBe('electron') - expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1') - const cleanupRun = runs.find((run) => - run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs') - ) - expect(cleanupRun).toContain( - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' - ) - expect(runs).toContain('pnpm verify:computer-native') - expect( - runs.find((run) => run.includes('config/scripts/build-native-for-platform.test.mjs')) - ).toContain('--config config/vitest.config.ts') - expect(runs.join('\n')).not.toContain('test:e2e:computer') - expect(workflow.jobs.mac).toBeUndefined() - expect(workflow.on.pull_request.paths).toEqual( - expect.arrayContaining([ - 'config/scripts/build-native-for-platform.mjs', - 'config/scripts/build-native-for-platform.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs', - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs', - 'config/scripts/macos-computer-helper-owner-loss-processes.mjs', - 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs' - ]) - ) - }) - it('uses the cached Electron dependency path for scheduled Linux and Windows e2e', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -220,29 +29,6 @@ describe('computer-use e2e workflow', () => { } }) - it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => { - const benchmark = readFileSync( - join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'), - 'utf8' - ) - const cleanup = readFileSync( - join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'), - 'utf8' - ) - - expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]') - expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]") - expect(benchmark).toContain('cleanupOwnerLossTrial({') - const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)') - const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({') - expect(parseIndex).toBeGreaterThanOrEqual(0) - expect(cleanupIndex).toBeGreaterThanOrEqual(0) - expect(parseIndex).toBeLessThan(cleanupIndex) - expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)') - expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath') - expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'") - }) - it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -279,34 +65,6 @@ describe('computer-use e2e workflow', () => { ) }) - it('re-runs the native-smoke job when the daemon bundle graph changes', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'config/scripts/daemon-boot-smoke.mjs', - 'config/scripts/windows-daemon-workspace-close-repro.mjs', - 'electron.vite.config.ts', - 'config/build-plugins/**', - 'src/main/daemon/**' - ]) - ) - }) - - it('does not run computer-use e2e in PR smoke jobs', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const nativeSmokeRuns = workflow.jobs['native-smoke'].steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - - expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer') - }) - it('builds Electron main output before every computer-use e2e run', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -352,48 +110,4 @@ describe('computer-use e2e workflow', () => { expect(allRuns.join('\n')).toContain('test:e2e:computer') expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter') }) - - it('runs Linux e2e on schedule without advertising hosted macOS TCC coverage', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - const linuxRuns = workflow.jobs.linux.steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'tests/e2e/computer-linux.e2e.ts', - 'tests/e2e/helpers/computer-cli-driver.ts', - 'tests/e2e/helpers/computer-driver.ts' - ]) - ) - expect(triggerPaths).not.toContain('tests/e2e/computer-mac.e2e.ts') - expect(triggerPaths).not.toContain('tests/e2e/computer-mac-safari.e2e.ts') - expect(workflow.jobs.mac).toBeUndefined() - expect(linuxRuns).toContain( - 'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts' - ) - }) - - it('runs every Windows computer-use e2e file in the scheduled Windows job', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - const windowsRuns = workflow.jobs.windows.steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'tests/e2e/computer-windows.e2e.ts', - 'tests/e2e/computer-windows-store.e2e.ts' - ]) - ) - expect(windowsRuns).toContain( - 'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts' - ) - }) }) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 72ffc80ec2c..fee07d5e299 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -162,6 +162,32 @@ describe('electron-builder config', () => { expect(packs('out/renderer/index.html')).toBe(true) }) + // Why: an AV verdict on the bundled relay.js used to take app.asar with it as a + // compound object, gutting the install (#20966). resources/relay is the only copy + // a packaged build resolves, so the asar copy was 14MB of pure blast radius. + it('keeps the relay bundles out of app.asar and ships them only through extraResources', () => { + const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files) + matcher.prependPattern('**/*') + const isPacked = matcher.createFilter() + const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false }) + + for (const relayPath of [ + 'out/relay/linux-x64/relay.js', + 'out/relay/win32-x64/relay.js', + 'out/relay/darwin-arm64/relay-watcher.js', + 'out/relay/wsl/wsl-agent-hook-relay.js' + ]) { + expect(packs(relayPath)).toBe(false) + } + + for (const platform of ['mac', 'linux', 'win']) { + expect(electronBuilderConfig[platform].extraResources).toContainEqual({ + from: 'out/relay', + to: 'relay' + }) + } + }) + it('keeps runtime resources available through extraResources', () => { const bundledPluginResources = expect.objectContaining({ from: 'resources/plugins/launch', diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 958e461ffd9..da6d45c8a23 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -20,9 +20,6 @@ import { createRequire } from 'node:module' import { electronViteConfig } from '../../electron.vite.config' import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' -const targetConfig = readFileSync('config/electron-vite-target.config.cts', 'utf8') -const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') - type BootstrapProcessMock = EventEmitter & { env: Record pid: number @@ -253,11 +250,6 @@ describe('Electron Vite output contract', () => { ) }) - it('rejects prototype properties as build targets', () => { - // Own-property check only: an inherited key like `constructor` must not select a build target. - expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)') - }) - it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => { // Asserted on the values rather than the source text: the ids moved into // dev-electron-bundle-identity.mjs so every dev bundle signs to one cdhash. @@ -265,7 +257,5 @@ describe('Electron Vite output contract', () => { expect(getDevHelperPlistPatches()).toEqual([ { key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID } ]) - expect(devRunner).toContain("'Electron Helper.app',") - expect(devRunner).toContain('setPlistValue(helperPlistPath, key, value)') }) }) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 77b92b1d0c3..d9de91f28da 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -4,53 +4,8 @@ import { parse } from 'yaml' import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8') -const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8') -const shutdownDockerRunner = readFileSync( - 'config/scripts/run-headless-serve-shutdown-docker.mjs', - 'utf8' -) -const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8') -const desktopStartupOracle = readFileSync( - 'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh', - 'utf8' -) -const headlessLinuxProse = headlessLinuxGuide.replace(/\s+/g, ' ') - -function readSystemdUnitBlocks(doc, unitName) { - const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return [...doc.matchAll(new RegExp(`^# /etc/systemd/system/${escapedUnitName}$`, 'gm'))].map( - (match) => { - const start = match.index + match[0].length - const end = doc.indexOf('```', start) - const nextUnitHeaderOffset = doc.slice(start).search(/^# \/etc\/systemd\/system\/.+$/m) - const nextUnitHeader = nextUnitHeaderOffset === -1 ? -1 : start + nextUnitHeaderOffset - if (end === -1 || (nextUnitHeader !== -1 && end > nextUnitHeader)) { - throw new Error(`Missing closing code fence for ${unitName}`) - } - return doc.slice(start, end) - } - ) -} describe('headless serve shutdown PR gate', () => { - it('reads only exact, closed systemd unit blocks', () => { - expect( - readSystemdUnitBlocks('# /etc/systemd/system/orca-serveXservice\n```', 'orca-serve.service') - ).toEqual([]) - expect(() => - readSystemdUnitBlocks('# /etc/systemd/system/orca-serve.service\n', 'orca-serve.service') - ).toThrow('Missing closing code fence for orca-serve.service') - expect(() => - readSystemdUnitBlocks( - '# /etc/systemd/system/orca-serve.service\n' + - 'KillMode=mixed\n' + - '# /etc/systemd/system/other.service\n```', - 'orca-serve.service' - ) - ).toThrow('Missing closing code fence for orca-serve.service') - }) - it('packages Linux artifacts before running the Docker signal oracle', () => { const steps = workflow.jobs.package.steps const packageStep = steps.find((step) => step.name === 'Package unpacked app') @@ -72,130 +27,4 @@ describe('headless serve shutdown PR gate', () => { steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs')) ).toHaveLength(1) }) - - it('keeps readiness polling finite and leak-free', () => { - expect(signalCase).toContain('read_ready_line()') - expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'") - expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}') - expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))') - expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do') - expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break') - expect(signalCase).toContain( - "jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F" - ) - expect(signalCase).not.toContain('tail --pid=') - }) - - it('gives owned shutdown state a bounded cleanup grace', () => { - expect(signalCase).toContain('for shutdown_poll in {0..50}; do') - expect(signalCase).toContain('[[ -z "$listener_after" && -z "$owned_residue" ]]') - expect(signalCase).toContain('((${#survivors[@]} == 0))') - expect(signalCase).toContain('((shutdown_poll < 50)) && sleep 0.1') - }) - - it('checks that a serving-electron signal target owns the ready socket', () => { - const ssRecord = - 'LISTEN 0 128 127.0.0.1:41235 0.0.0.0:* users:(("orca-ide",pid=23,fd=7),("orca-ide",pid=25,fd=8))' - expect([...ssRecord.matchAll(/pid=([0-9]+)/g)].map((match) => match[1])).toEqual(['23', '25']) - expect(signalCase).toContain( - 'listener_before_pids=$(grep -oE \'pid=[0-9]+\' <<<"$listener_before" | cut -d= -f2 || true)' - ) - expect(signalCase).toContain('signal_target_pid=$(head -n1 <<<"$listener_before_pids")') - expect(signalCase).toContain('outside the entrypoint process tree') - }) - - it('runs the original AppImage desktop startup oracle before extraction and signals', () => { - expect(shutdownDockerfile).toContain( - 'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case' - ) - const startupCall = shutdownDockerRunner.indexOf( - 'runDesktopStartupOracle({ image, appImage, platform })' - ) - const extractionCall = shutdownDockerRunner.indexOf( - "'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract" - ) - const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])") - expect(startupCall).toBeGreaterThan(-1) - expect(extractionCall).toBeGreaterThan(startupCall) - expect(signalLoop).toBeGreaterThan(startupCall) - expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'") - }) - - it('preserves startup logs when the launcher exits before its marker', () => { - expect(desktopStartupOracle).toContain('signal_process_group TERM || true') - expect(desktopStartupOracle).toContain('signal_process_group KILL || true') - expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true') - expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true') - expect(desktopStartupOracle).toContain( - 'FAIL: desktop launcher exited before ${reason} (status=${observed_status})' - ) - expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1') - expect(desktopStartupOracle).toContain( - '[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0' - ) - }) - - it('requires the bound AppImage to be executable before launch and extraction', () => { - expect(desktopStartupOracle).toContain( - '[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }' - ) - expect(shutdownDockerRunner).toContain( - '\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\'' - ) - }) - - it('gives the original AppImage enough bounded extraction space', () => { - expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'") - }) - - it('keeps owned Xvfb alive during the documented systemd graceful stop', () => { - const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service') - const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit)) - const managedXvfbUnits = serveUnits.filter((unit) => /^Environment=DISPLAY=/m.test(unit)) - - expect(ownedXvfbUnits).toHaveLength(1) - expect(ownedXvfbUnits[0]).toMatch(/^ExecStart=.*orca-linux\.AppImage serve.*$/m) - expect(ownedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m) - expect(managedXvfbUnits).toHaveLength(1) - expect(managedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m) - }) - - it('distinguishes persisted state from live work during a service restart', () => { - expect(headlessLinuxProse).toContain( - 'The detached terminal daemon is preserved by a different mechanism: it is launched through `systemd-run --user --scope`' - ) - expect(headlessLinuxProse).toContain( - 'These guarantees preserve live processes only when the daemon is in its own' - ) - expect(headlessLinuxProse).toContain( - 'The unscoped fallback remains destructive: a service restart kills every terminal' - ) - expect(headlessLinuxProse).toContain( - 'Treat a stop as destructive unless `health.terminalDaemon.cgroupUnit` names an `orca-daemon-*.scope` on that host' - ) - expect(headlessLinuxProse).toContain( - 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, failed request or lost connection is `unverifiable`' - ) - expect(headlessLinuxGuide).toContain( - 'sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json' - ) - expect(headlessLinuxGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json') - expect(headlessLinuxGuide).not.toContain('Two facts make this safe and predictable') - }) - - it('uses the registered CLI name from ordinary Linux shells', () => { - const commandRule = - 'The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing the GNOME Orca screen reader.' - const substitutionRule = - "From an ordinary shell outside that service user's managed environment, substitute `orca-ide` for `orca` in commands below." - const censusCommand = '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`' - - expect(headlessLinuxProse).toContain(commandRule) - expect(headlessLinuxProse).toContain(substitutionRule) - expect(headlessLinuxProse).toContain(censusCommand) - expect(headlessLinuxGuide).toContain('best-effort dispatcher at `$HOME/.local/bin/orca`') - expect(headlessLinuxProse.indexOf(substitutionRule)).toBeLessThan( - headlessLinuxProse.indexOf(censusCommand) - ) - }) }) diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index 3991a886ab5..ed347670277 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -2,75 +2,86 @@ import assert from 'node:assert/strict' import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import { build } from 'esbuild' -// Pass a directory containing journal-open.ts and journal-row-table.ts from the base commit. +// Pass the root of a checkout (or `git archive src` extract) of the base commit: each arm is +// bundled whole from its own tree and writes its own fixture, since the two store history differently. const baselineDir = process.argv[2] assert.ok( baselineDir, - 'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_DIR' + 'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_CHECKOUT_ROOT' ) assert.ok(global.gc, 'Run with --expose-gc to measure live backing memory during replay') const root = fileURLToPath(new URL('../..', import.meta.url)) +const journalSource = './src/main/native-chat/agent-session-journal' +const entries = { + baseline: `export {openAgentSessionJournal} from '${journalSource}/journal-store-factory'; export {replayJournal} from '${journalSource}/journal-open'; export {openJournalDatabase} from '${journalSource}/journal-database'; export {journalDatabaseFile} from '${journalSource}/journal-paths';`, + current: `export {openAgentSessionJournal} from '${journalSource}/journal-store-factory'; export {replayJournal} from '${journalSource}/journal-open'; export {JournalHostDatabase, journalDatabasePath} from '${journalSource}/journal-host-database';` +} +const sourceRoots = { baseline: resolve(baselineDir), current: root } +const identity = { + sessionId: 'benchmark', + workspaceId: 'fixture', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread' } +} const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-')) -try { - const implementations = {} - for (const arm of ['baseline', 'current']) { - const outfile = join(fixture, `${arm}.cjs`) - await build({ - stdin: { - contents: - "export {openAgentSessionJournal} from './src/main/native-chat/agent-session-journal/journal-store-factory'; export {loadJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {journalDatabaseFile} from './src/main/native-chat/agent-session-journal/journal-paths';", - resolveDir: root - }, - bundle: true, - platform: 'node', - format: 'cjs', - outfile, - plugins: [ - { - name: 'replay-memory-probe', - setup(plugin) { - plugin.onLoad( - { filter: /journal-(?:open|row-table|reducer)\.ts$/ }, - async ({ path }) => { - const leaf = basename(path) - let source = await readFile( - arm === 'baseline' && leaf !== 'journal-reducer.ts' - ? join(baselineDir, leaf) - : path, - 'utf8' - ) - if (leaf === 'journal-reducer.ts') { - const marker = - 'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {' - assert.ok(source.includes(marker)) - source = source.replace( - marker, - `${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);` - ) - } - return { contents: source, loader: 'ts', resolveDir: dirname(path) } - } - ) - } +// Released in `finally`, newest first: an open SQLite handle blocks the fixture's removal on Windows. +const releases = [] + +async function bundle(arm) { + const outfile = join(fixture, `${arm}.cjs`) + await build({ + stdin: { contents: entries[arm], resolveDir: sourceRoots[arm] }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + // A bare base checkout has no node_modules of its own. + nodePaths: [join(root, 'node_modules')], + plugins: [ + { + name: 'replay-memory-probe', + setup(plugin) { + plugin.onLoad({ filter: /journal-reducer\.ts$/ }, async ({ path }) => { + const marker = + 'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {' + const source = await readFile(path, 'utf8') + assert.ok(source.includes(marker), `${basename(path)} lost the probe marker`) + return { + contents: source.replace( + marker, + `${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);` + ), + loader: 'ts', + resolveDir: dirname(path) + } + }) } - ] - }) - implementations[arm] = createRequire(import.meta.url)(outfile) + } + ] + }) + return createRequire(import.meta.url)(outfile) +} + +/** One long-revised item, written by the arm's own store; returns the arm's replay of it. */ +async function openArm(arm) { + const implementation = await bundle(arm) + const stateDirectory = join(fixture, arm) + let journal + let database + if (arm === 'baseline') { + journal = await implementation.openAgentSessionJournal({ identity, journalDir: stateDirectory }) + } else { + database = implementation.JournalHostDatabase.open(stateDirectory) + releases.push(() => database.close()) + journal = await implementation.openAgentSessionJournal({ identity, database }) } - const identity = { - sessionId: 'benchmark', - workspaceId: 'fixture', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread' } - } - const journalDir = join(fixture, 'session') - const journal = await implementations.current.openAgentSessionJournal({ identity, journalDir }) + releases.push(() => journal.close()) const item = { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 } const text = 'x'.repeat(32768) for (let revision = 0; revision < 2000; revision++) { @@ -84,11 +95,29 @@ try { { fence: 1 } ) } - await journal.close() + await releases.pop()() + if (arm === 'current') { + return { + path: implementation.journalDatabasePath(stateDirectory), + replay: () => implementation.replayJournal(database.db, identity.sessionId) + } + } + const path = implementation.journalDatabaseFile(stateDirectory) + const opened = implementation.openJournalDatabase(path) + releases.push(() => opened.db.close()) + return { + path, + // The base replay takes the connection's read-only flag before the chat. + replay: () => implementation.replayJournal(opened.db, opened.readOnly, identity.sessionId) + } +} + +try { + const arms = { baseline: await openArm('baseline'), current: await openArm('current') } for (const arm of ['baseline', 'current', 'current', 'baseline']) { global.gc() const start = performance.now() - let loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + let loaded = arms[arm].replay() const ms = performance.now() - start assert.equal(loaded.state.items.size, 1) assert.equal([...loaded.state.items.values()][0].revision, 2000) @@ -103,7 +132,7 @@ try { global.gc() peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed) } - loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + loaded = arms[arm].replay() delete globalThis.__replayMemoryProbe assert.equal(loaded.state.items.size, 1) loaded = null @@ -111,12 +140,19 @@ try { JSON.stringify({ arm, ms, - databaseBytes: (await stat(implementations[arm].journalDatabaseFile(journalDir))).size, + databaseBytes: (await stat(arms[arm].path)).size, peakLiveHeapDelta: peakLiveHeap - initialHeap }) ) } } finally { delete globalThis.__replayMemoryProbe + for (const release of releases.toReversed()) { + try { + await release() + } catch (error) { + console.error('[journal-replay-retention-benchmark] release failed', error) + } + } await rm(fixture, { recursive: true, force: true }) } diff --git a/config/scripts/mobile-recording-pin-checkout.test.mjs b/config/scripts/mobile-recording-pin-checkout.test.mjs deleted file mode 100644 index 7ff6251afd6..00000000000 --- a/config/scripts/mobile-recording-pin-checkout.test.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { expect, it } from 'vitest' -import { parse } from 'yaml' -import { runProcessSync } from './script-child-process.mjs' - -const workflow = parse( - readFileSync(new URL('../../.github/workflows/mobile.yml', import.meta.url), 'utf8') -) - -it('keeps full ancestry and credentials for lazy pinned-tree reads', () => { - const job = workflow.jobs['recording-pin'] - const checkout = job.steps.find((step) => step.uses?.startsWith('actions/checkout@')) - expect(checkout.with['fetch-depth']).toBe(0) - expect(checkout.with.filter).toBe('blob:none') - expect(checkout.with['persist-credentials']).not.toBe(false) - expect(job.if).toBeUndefined() - expect(workflow.on.push.branches).toEqual(['main']) - expect(workflow.concurrency.group).toContain('github.sha') - const reachable = job.steps.find((step) => step.name === 'Check the recording pin is reachable') - expect(reachable.run).toBe('pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable') - const reproduce = job.steps.find( - (step) => step.name === 'Reproduce the corpus from the pinned tree' - ) - // Both steps ask GitHub which pull requests hold a pin main's history lacks. - expect(job.permissions).toEqual({ contents: 'read', 'pull-requests': 'read' }) - expect(reachable.env.GITHUB_TOKEN).toContain('github.token') - expect(reproduce.env.GITHUB_TOKEN).toContain('github.token') - expect(reproduce.run).toContain('reproduce --if-changed-since "$PIN_GUARD_BASE"') - expect(reproduce.run).toContain( - 'else\n pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce\nfi' - ) -}) - -it('retains ancestry while fetching a missing pinned blob for a detached worktree', () => { - const directory = mkdtempSync(join(tmpdir(), 'mobile-pin-checkout-')) - const source = join(directory, 'source') - const checkout = join(directory, 'checkout') - const pinnedTree = join(directory, 'pinned-tree') - const git = (cwd, ...args) => { - const result = runProcessSync({ program: 'git', args, cwd }) - expect(result.code, result.stderr).toBe(0) - return result.stdout.trim() - } - try { - git(directory, 'init', '--quiet', source) - git(source, 'symbolic-ref', 'HEAD', 'refs/heads/main') - git(source, 'config', 'user.name', 'Pin checkout fixture') - git(source, 'config', 'user.email', 'pin-checkout@example.invalid') - git(source, 'config', 'uploadpack.allowFilter', 'true') - git(source, 'config', 'uploadpack.allowAnySHA1InWant', 'true') - const corpus = 'mobile/rpc-foundation/goldens/fixture.json' - mkdirSync(join(source, 'mobile/rpc-foundation/goldens'), { recursive: true }) - const original = '{"baseline":"original historical recording"}\n' - const commit = () => { - git(source, 'add', '-A') - git(source, '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'recording') - return git(source, 'rev-parse', 'HEAD') - } - writeFileSync(join(source, corpus), original) - const baseline = commit() - const oldBlob = git(source, 'rev-parse', `${baseline}:${corpus}`) - writeFileSync(join(source, corpus), '{"baseline":"current recording"}\n') - commit() - git( - directory, - 'clone', - '--filter=blob:none', - '--no-checkout', - '--single-branch', - '--no-tags', - pathToFileURL(source).href, - checkout - ) - git(checkout, 'checkout', '--quiet', '--force', 'main') - - expect(git(checkout, 'rev-parse', '--is-shallow-repository')).toBe('false') - expect(git(checkout, 'rev-list', '--count', 'HEAD')).toBe('2') - git(checkout, 'merge-base', '--is-ancestor', baseline, 'HEAD') - expect(git(checkout, 'rev-list', '--objects', '--missing=print', 'HEAD')).toContain( - `?${oldBlob}` - ) - - git(checkout, 'worktree', 'add', '--detach', pinnedTree, baseline) - - expect(readFileSync(join(pinnedTree, corpus), 'utf8')).toBe(original) - expect(git(checkout, 'rev-list', '--objects', '--missing=print', 'HEAD')).not.toContain( - `?${oldBlob}` - ) - git(checkout, 'worktree', 'remove', '--force', pinnedTree) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -}) diff --git a/config/scripts/mobile-release-check-scope.mjs b/config/scripts/mobile-release-check-scope.mjs index 435d3e6fccc..aeaf276842f 100644 --- a/config/scripts/mobile-release-check-scope.mjs +++ b/config/scripts/mobile-release-check-scope.mjs @@ -3,6 +3,9 @@ import { pathToFileURL } from 'node:url' import { isDocsOnlyPath } from './pr-code-change-scope.mjs' const APPLICATION_PREFIXES = ['src/', 'mobile/app/', 'mobile/src/'] +// The root lockfile triggers Mobile Checks for its tests and typechecks; the Ruby release checks +// read no root Node dependency. +const NON_RELEASE_FILES = new Set(['mobile/README.md', 'pnpm-lock.yaml']) export function shouldRunMobileReleaseChecks(files) { return ( @@ -10,7 +13,7 @@ export function shouldRunMobileReleaseChecks(files) { files.some( (file) => !isDocsOnlyPath(file) && - file !== 'mobile/README.md' && + !NON_RELEASE_FILES.has(file) && !file.startsWith('mobile/docs/') && !( APPLICATION_PREFIXES.some((prefix) => file.startsWith(prefix)) && diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index cd7c204f817..480d1cc1257 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -51,7 +51,6 @@ it.each([ 'mobile/src/release.json', 'mobile/new-toolchain/input', 'package.json', - 'pnpm-lock.yaml', '.github/workflows/mobile.yml', '.github/workflows/mobile-ios-release.yml', '.github/actions/install-node-dependencies/action.yml', @@ -62,6 +61,11 @@ it.each([ expect(shouldRunMobileReleaseChecks(['mobile/src/view.tsx', file])).toBe(true) }) +it('skips Ruby checks for a root lockfile change, which fastlane never reads', () => { + expect(shouldRunMobileReleaseChecks(['pnpm-lock.yaml'])).toBe(false) + expect(shouldRunMobileReleaseChecks(['pnpm-lock.yaml', 'mobile/fastlane/Fastfile'])).toBe(true) +}) + it('runs Ruby checks when the changed-file evidence is empty', () => { expect(shouldRunMobileReleaseChecks([])).toBe(true) }) @@ -72,7 +76,12 @@ it('gates Ruby independently and retains mobile static validation', () => { expect(steps.indexOf(detector)).toBeGreaterThan( steps.findIndex((step) => step.uses === './.github/actions/install-node-dependencies') ) - const gated = steps.filter((step) => step.if !== undefined && step.name !== 'Test') + const gated = steps.filter( + (step) => + step.if !== undefined && + step.name !== 'Test' && + step.name !== 'Summarize RPC recording changes' + ) expect(gated.map((step) => step.name)).toEqual([ 'Setup Ruby and fastlane', 'Test iOS release version resolution', diff --git a/config/scripts/mobile-test-change-scope.test.mjs b/config/scripts/mobile-test-change-scope.test.mjs index 8859daca448..48e584e66cc 100644 --- a/config/scripts/mobile-test-change-scope.test.mjs +++ b/config/scripts/mobile-test-change-scope.test.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { matchesGlob } from 'node:path' import { parse } from 'yaml' import { expect, it } from 'vitest' import { shouldRunMobileTests } from './mobile-test-change-scope.mjs' @@ -42,6 +43,17 @@ it('retains tests on missing evidence and a move out of the source tree', () => expect(shouldRunMobileTests(['mobile/src/deleted.ts', 'mobile/docs/moved.ts'])).toBe(true) }) +it('runs Mobile Checks on any shared-module or root lockfile change, on a pull request and on main', () => { + const workflow = parse( + readFileSync(new URL('../../.github/workflows/mobile.yml', import.meta.url), 'utf8') + ) + for (const paths of [workflow.on.pull_request.paths, workflow.on.push.paths]) { + for (const file of ['src/shared/any/module.ts', 'pnpm-lock.yaml']) { + expect(paths.some((pattern) => matchesGlob(file, pattern))).toBe(true) + } + } +}) + it('skips only tests, after successful detection, and retains all other mobile gates', () => { const workflow = parse( readFileSync(new URL('../../.github/workflows/mobile.yml', import.meta.url), 'utf8') diff --git a/config/scripts/mobile-web-app-route-manifest.test.mjs b/config/scripts/mobile-web-app-route-manifest.test.mjs index b4c29d8d9d8..1cbf256bd7a 100644 --- a/config/scripts/mobile-web-app-route-manifest.test.mjs +++ b/config/scripts/mobile-web-app-route-manifest.test.mjs @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -250,23 +250,3 @@ describe('the synthesized RequireContext', () => { expect(() => context('constructor')).toThrow('no route module') }) }) - -describe('the web entry', () => { - it('leaves the suspense boundary to expo-router', async () => { - const entry = await readFile(join(projectDir, 'mobile', 'web-entry', 'index.tsx'), 'utf8') - // A second boundary around the whole tree catches nothing the router has not already caught, - // and would only make the fallback ambiguous about which layer suspended. - expect(entry).not.toContain('Suspense') - }) - - itBundling('because the router already wraps every screen in one', async () => { - // The premise of the test above, read off the copy that is bundled: getQualifiedRouteComponent - // wraps each screen itself, which is what makes the lazy route manifest safe without a - // boundary of our own. - const useScreens = await readFile( - join(projectDir, 'mobile', 'node_modules', 'expo-router', 'build', 'useScreens.js'), - 'utf8' - ) - expect(useScreens).toContain(' { expect(dictationGrantsNeeded({ local: ['src/platform/media-picker.web.ts'] })).toEqual([]) }) - it('records what the seam keeps out, in the number that was measured', () => { - expect(NATIVE_AUDIO_MODULES_BEHIND_THE_SEAM).toBe(8) - expect(NATIVE_AUDIO_MODULES).toHaveLength(2) - }) - /** * Gated on the mobile install, not merely deferred behind `import()`. * diff --git a/config/scripts/mobile-web-page-routes.test.mjs b/config/scripts/mobile-web-page-routes.test.mjs index 88dc992d34f..3cc77197c61 100644 --- a/config/scripts/mobile-web-page-routes.test.mjs +++ b/config/scripts/mobile-web-page-routes.test.mjs @@ -1,20 +1,7 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { MobileWebBundleRouteSchema } from '../../src/shared/mobile-web-bundle/manifest-contract.ts' -import { - buildMobileWebAppBundle, - resolveMobileWebPageRoutes -} from './build-mobile-web-app-bundle.mjs' -import { computeMobileWebBundleBuildId } from './mobile-web-bundle-manifest.mjs' -import { - collectMobileWebAppRouteKeys, - routePathnameFromKey -} from './mobile-web-app-route-manifest.mjs' -import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' -import { spelledCountsAgainstTables } from './spelled-count-census.mjs' +import { resolveMobileWebPageRoutes } from './build-mobile-web-app-bundle.mjs' +import { routePathnameFromKey } from './mobile-web-app-route-manifest.mjs' /** * Which screens this desktop declares as page routes, and whether the bundle can render each. @@ -25,98 +12,6 @@ import { spelledCountsAgainstTables } from './spelled-count-census.mjs' * to register would have had to choose between a lint fence and a split it did not ask for. */ -const projectDir = fileURLToPath(new URL('../..', import.meta.url)) -const appDir = join(projectDir, 'mobile', 'app') - -// The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over -// the route tree is skipped there and run for real in pr.yml's mobile_web_app job. -const itBundling = mobileWebAppDependenciesPresent() ? it : it.skip - -async function withScratch(run) { - const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-page-routes-test-')) - try { - return await run(scratch) - } finally { - await rm(scratch, { recursive: true, force: true }) - } -} - -/** - * Every page route this bundle declares, written out rather than read from the source that - * produces it: the point is to pin the list, and comparing the manifest to its own input would - * pass whatever that input became. Shared by the assertions below, which read it two ways: what - * the route tree resolves to, and what the built manifest actually carries. - */ -const EXPECTED_PAGE_ROUTES = [ - { pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'externalLink', 'haptics'] }, - { - pathname: '/h/[hostId]/agent-history/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink', 'haptics'] - }, - { - pathname: '/h/[hostId]/tasks', - grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] - }, - { - pathname: '/h/[hostId]/files/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink', 'haptics'] - }, - { - pathname: '/h/[hostId]/files/preview/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink', 'haptics'] - }, - { - pathname: '/h/[hostId]/source-control/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] - }, - { - pathname: '/h/[hostId]/review/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] - }, - { - pathname: '/h/[hostId]/session/[worktreeId]', - grants: [ - 'navigate', - 'storage', - 'externalLink', - 'haptics', - 'screencastBinary', - 'native.clipboard.write', - 'native.clipboard.read', - 'native.media.pick', - 'native.media.read', - 'native.media.release', - 'native.audio.start', - 'native.audio.read', - 'native.audio.stop' - ], - // The one optional grant in the list (C8.1): the HTML preview's links, hidden rather than dead - // against a shell that cannot open one. - optionalGrants: ['externalNavigation'] - } -] - -const sessionGrants = EXPECTED_PAGE_ROUTES.filter( - (route) => route.pathname === '/h/[hostId]/session/[worktreeId]' -).flatMap((route) => route.grants) - -const sessionOptionalGrants = EXPECTED_PAGE_ROUTES.filter( - (route) => route.pathname === '/h/[hostId]/session/[worktreeId]' -).flatMap((route) => route.optionalGrants ?? []) - -const withPrefix = (prefix) => sessionGrants.filter((grant) => grant.startsWith(prefix)) - -/** Every count this table's own comments spell out, beside the list each is a count of. */ -const SPELLED_COUNTS = [ - { precedes: 'grants', counted: sessionGrants.length }, - { precedes: 'audio verbs', counted: withPrefix('native.audio.').length }, - { precedes: 'media verbs', counted: withPrefix('native.media.').length }, - // "All three or none": the audio verbs again, as the rule that they are declared together. - { precedes: 'or none', counted: withPrefix('native.audio.').length }, - // C8.1's, and the count the optional lane will grow first. - { precedes: 'optional grant', counted: sessionOptionalGrants.length } -] - describe('the page routes the manifest declares', () => { it('turns a route key into the URL pattern expo-router gives it', () => { expect(routePathnameFromKey('./h/[hostId]/index.tsx')).toBe('/h/[hostId]') @@ -131,24 +26,6 @@ describe('the page routes the manifest declares', () => { expect(routePathnameFromKey('./h/[hostId]/_layout.tsx')).toBeNull() }) - it("spells the session route's own counts off the table it comments", async () => { - const source = await readFile( - join(projectDir, 'config', 'scripts', 'mobile-web-page-routes.mjs'), - 'utf8' - ) - for (const { precedes, spelled, counts } of spelledCountsAgainstTables( - source, - SPELLED_COUNTS - )) { - expect(spelled, precedes).toEqual(counts) - } - }) - - it('declares only routes the bundle has a module for', async () => { - const keys = await collectMobileWebAppRouteKeys(appDir) - expect(resolveMobileWebPageRoutes(keys)).toEqual(EXPECTED_PAGE_ROUTES) - }) - /** * The optional lane through the builder, which drops what it does not name. * @@ -200,18 +77,4 @@ describe('the page routes the manifest declares', () => { ) ).toThrow('has no module in the bundle') }) - - itBundling( - 'reaches the built manifest, where the build id does not move for it', - async () => { - await withScratch(async (scratch) => { - const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) - expect(manifest.routes).toEqual(EXPECTED_PAGE_ROUTES) - // The routes are derived from the same tree the script is built from, so the assets - // already decide them and the id has no reason to carry them as well. - expect(manifest.buildId).toBe(computeMobileWebBundleBuildId(manifest.assets)) - }) - }, - 240_000 - ) }) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 7ede0fc6208..b93094901a0 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -3,7 +3,6 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts' const projectDir = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -25,18 +24,10 @@ describe('Electron runtime package contract', () => { } it('keeps the native Windows registry addon optional and platform-gated', () => { - const rebuildScript = readProject('config/scripts/rebuild-native-deps.mjs') - const ensureScript = readProject('config/scripts/ensure-native-runtime.mjs') expect(packageJson.optionalDependencies['@orca/windows-registry']).toBe('workspace:*') // Why: allowBuilds stops pnpm running node-gyp at install time -- the root // Windows-only rebuild owns this addon so it is built against the right runtime ABI. expect(pnpmWorkspace.allowBuilds['@orca/windows-registry']).toBe(false) - // Why assert the guard and the member separately: the list now carries more - // than one addon, so pinning the whole literal only tested its formatting. - expect(rebuildScript).toContain("rebuildPlatform === 'win32'") - expect(rebuildScript).toContain("'@orca/windows-registry'") - expect(ensureScript).toContain("process.platform === 'win32'") - expect(ensureScript).toContain("'@orca/windows-registry'") if (windowsAddonsInstalled) { expect(packageTargets.win32).toEqual( expect.arrayContaining([ @@ -55,20 +46,10 @@ describe('Electron runtime package contract', () => { }) it('keeps the native Windows process-table addon optional and platform-gated', () => { - const rebuildScript = readFileSync( - join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), - 'utf8' - ) - const ensureScript = readFileSync( - join(projectDir, 'config/scripts/ensure-native-runtime.mjs'), - 'utf8' - ) expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0') // Why: same rule as the registry addon -- allowBuilds stops pnpm running node-gyp at // install time so the Windows-only rebuild owns it with the right runtime ABI. expect(pnpmWorkspace.allowBuilds['@vscode/windows-process-tree']).toBe(false) - expect(rebuildScript).toContain("'@vscode/windows-process-tree'") - expect(ensureScript).toContain("'@vscode/windows-process-tree'") // Why pin the patch: the upstream binding.gyp requires Spectre-mitigated // libraries our build agents do not carry, and the enumeration stops after // 1024 processes -- on a busy host that silently hides the very descendants @@ -92,195 +73,6 @@ describe('Electron runtime package contract', () => { } }) - it('guards package scripts that launch Electron tooling', () => { - const scripts = packageJson.scripts - const guardedScripts = [ - 'start', - 'dev', - 'dev-stable-name', - 'build:unpack', - 'build:win', - 'build:mac', - 'build:mac:release', - 'build:linux', - 'test:e2e', - 'test:e2e:terminal-rendering-golden', - 'test:e2e:posix-profile-index-golden', - 'test:e2e:terminal-rendering-release-evidence', - 'test:e2e:headful' - ] - - for (const scriptName of guardedScripts) { - expect(scripts[scriptName], scriptName).toContain('pnpm run ensure:electron-runtime &&') - } - }) - - it('keeps Windows and Linux package builds off macOS native helper builds', () => { - const scripts = packageJson.scripts - - expect(scripts['build:desktop']).not.toContain('build:computer-macos') - expect(scripts['build:desktop']).not.toContain('build:keyboard-layout-macos') - expect(scripts['build:win']).toContain('pnpm run build:desktop') - expect(scripts['build:win']).not.toContain('pnpm run build ') - expect(scripts['build:win']).not.toContain('build:computer-macos') - expect(scripts['build:win']).not.toContain('build:keyboard-layout-macos') - expect(scripts['build:linux']).toContain('pnpm run build:desktop') - expect(scripts['build:linux']).not.toContain('pnpm run build ') - expect(scripts['build:linux']).not.toContain('build:computer-macos') - expect(scripts['build:linux']).not.toContain('build:keyboard-layout-macos') - expect(scripts['build:mac']).toContain('pnpm run build:computer-macos') - expect(scripts['build:mac']).toContain('pnpm run build:keyboard-layout-macos') - expect(scripts['build:release']).toContain('pnpm run build:native') - expect(scripts['build:release']).not.toContain('build:computer-macos') - }) - - it('runs the web build through the heap-sized Vite wrapper', () => { - expect(packageJson.scripts['build:web']).toContain('node config/scripts/run-vite-web-build.mjs') - expect(packageJson.scripts['build:web']).toContain('node config/scripts/verify-web-build.mjs') - }) - - it('guards release publishing before electron-builder runs', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const macWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') - ) - const releaseCommands = new Map( - parsedWorkflow.jobs.build.strategy.matrix.include.map(({ platform, release_command }) => [ - platform, - release_command - ]) - ) - const macReleaseCommand = macWorkflow.jobs['build-mac'].steps.find( - (step) => step.name === 'Publish release artifacts (macOS)' - ).with.command - - expect([...releaseCommands.keys()].sort()).toEqual(['linux-arm64', 'linux-x64', 'win']) - for (const command of [...releaseCommands.values(), macReleaseCommand]) { - expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron') - expect(command).toContain('electron-builder') - expect(command.indexOf('ensure-native-runtime')).toBeLessThan( - command.indexOf('electron-builder') - ) - } - expect(macReleaseCommand).toContain(' && ORCA_MAC_RELEASE=1 ') - expect(releaseCommands.get('linux-x64')).toContain(' && pnpm exec electron-builder ') - expect(releaseCommands.get('linux-x64')).toContain('--linux AppImage deb rpm --x64') - expect(releaseCommands.get('linux-arm64')).toContain('ORCA_LINUX_ARM64_RELEASE=1') - expect(releaseCommands.get('linux-arm64')).toContain('--linux AppImage deb rpm --arm64') - expect(releaseCommands.get('win')).toContain( - '; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder ' - ) - }) - - it('blocks Linux and macOS release packaging on watcher process fault recovery', () => { - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const macWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') - ) - const assertFaultGate = (steps, publishStepName, expectedCondition) => { - const names = steps.map((step) => step.name) - const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation') - - expect(gate.if).toBe(expectedCondition) - expect(gate['continue-on-error']).toBeUndefined() - expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs') - expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron') - expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) - expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) - } - - assertFaultGate( - releaseWorkflow.jobs.build.steps, - 'Publish release artifacts (Linux)', - "runner.os == 'Linux'" - ) - assertFaultGate( - macWorkflow.jobs['build-mac'].steps, - 'Publish release artifacts (macOS)', - undefined - ) - }) - - it('packages and release-gates the SSH relay watcher child', () => { - const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') - const builderConfig = readFileSync( - join(projectDir, 'config/electron-builder.config.cjs'), - 'utf8' - ) - const remoteCommands = readFileSync( - join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'), - 'utf8' - ) - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const macWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') - ) - - expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") - expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") - expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')") - expect(builderConfig).toContain("from: 'out/relay'") - - // Hashing and remote install probing are manifest-driven, so the contract - // is that both companions are declared once and that both sites read it. - expect(relayArtifactFilenames(true)).toContain('relay-watcher.js') - expect(relayArtifactFilenames(true)).toContain('relay-ai-vault-service.js') - expect(relayBuild).toContain('relayArtifactFilenames(') - expect(remoteCommands).toContain('relayArtifactFilenames(') - - const assertRelayGate = (steps, publishStepName) => { - const names = steps.map((step) => step.name) - const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation') - expect(gate['continue-on-error']).toBeUndefined() - expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs') - expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) - expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) - } - - assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)') - assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)') - const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name) - expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan( - releaseNames.indexOf('Build Windows release artifacts') - ) - }) - - it('packages and verifies the Windows SSH node-pty console-list fallback', () => { - const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') - const relayDeploy = readFileSync(join(projectDir, 'src/main/ssh/ssh-relay-deploy.ts'), 'utf8') - const patchAsset = readFileSync( - join(projectDir, 'config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs'), - 'utf8' - ) - - expect(relayBuild).toContain('copyFileSync(') - expect(relayBuild).toContain('hash.update(readFileSync') - expect(relayBuild).toContain('node-pty-1.1.0-console-list-agent-patch.cjs') - expect(relayDeploy).toContain('assertPatchedNodePtyConsoleListAgent') - expect(relayDeploy.match(/\$\{windowsNodePtyPatchCommand\(nodePath\)\}/g)).toHaveLength(2) - expect(patchAsset).toContain('consoleProcessList = [shellPid];') - expect(patchAsset).toContain('packageJson.version !== EXPECTED_NODE_PTY_VERSION') - }) - - it('pins the Windows release builder to the VS 2022 runner image', () => { - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const windowsReleaseEntry = releaseWorkflow.jobs.build.strategy.matrix.include.find( - ({ platform }) => platform === 'win' - ) - - expect(windowsReleaseEntry.os).toBe('windows-2022') - }) - it('keeps release-cut signing provenance on GitHub-hosted runners', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') @@ -306,360 +98,4 @@ describe('Electron runtime package contract', () => { expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build') expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build-mac') }) - - it('runs the macOS release build in an isolated Blacksmith workflow', () => { - const releaseMacWorkflowText = readFileSync( - join(projectDir, '.github/workflows/release-mac-build.yml'), - 'utf8' - ) - const releaseMacWorkflow = parse(releaseMacWorkflowText) - const buildMacJob = releaseMacWorkflow.jobs['build-mac'] - const checkoutStep = buildMacJob.steps.find((step) => step.name === 'Checkout') - const publishStep = buildMacJob.steps.find( - (step) => step.name === 'Publish release artifacts (macOS)' - ) - - expect(releaseMacWorkflow['run-name']).toBe( - 'Mac release build ${{ inputs.tag }} (${{ inputs.release_run_id }})' - ) - expect(releaseMacWorkflow.on.workflow_dispatch.inputs.tag.required).toBe(true) - expect(releaseMacWorkflow.on.workflow_dispatch.inputs.release_run_id.required).toBe(true) - expect(buildMacJob['runs-on']).toBe('blacksmith-6vcpu-macos-15') - expect(checkoutStep.with.ref).toBe('refs/tags/${{ inputs.tag }}') - expect(publishStep.with.command).toContain('ORCA_MAC_RELEASE=1') - expect(publishStep.with.command).toContain('electron-builder') - expect(publishStep.with.command).toContain('--mac --publish always') - expect(releaseMacWorkflowText).not.toContain('signpath/') - expect(releaseMacWorkflowText).not.toContain('SIGNPATH_') - }) - - it('publishes both Linux release matrix entries', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const publishLinuxStep = parsedWorkflow.jobs.build.steps.find( - (step) => step.name === 'Publish release artifacts (Linux)' - ) - - expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-x64'") - expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-arm64'") - expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}') - }) - - it('keeps Linux postinstall repairing Chromium sandbox permissions', () => { - const afterInstallScript = readFileSync( - join(projectDir, 'resources/linux/packaging/after-install.sh'), - 'utf8' - ) - - expect(afterInstallScript).toContain('chrome-sandbox') - expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') - expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') - expect(afterInstallScript).toContain('is_owned_link()') - expect(afterInstallScript).toContain('readlink -f -- "$link"') - expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]') - expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]') - }) - - it('advances only the skill release ledger in a taggable release-cut commit', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') - const bumpStep = parsedWorkflow.jobs.cut.steps.find( - (step) => step.name === 'Bump package.json and tag' - ) - - const bumpIndex = bumpStep.run.indexOf( - 'npm version "$VERSION" --no-git-tag-version --allow-same-version' - ) - const generateIndex = bumpStep.run.indexOf( - 'node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"' - ) - const commands = bumpStep.run.replace(/^\s*#.*$/gm, '') - // Unanchored: a `git add` chained after `&&` stages just as effectively. - const stagedPaths = [...commands.matchAll(/\bgit add (.+)$/gm)].flatMap((match) => - match[1].trim().split(/\s+/) - ) - // Quotes trimmed and deduped: the index guard names the row a second time. - const mentioned = new Set(commands.match(/resources[/\\]skills[^\s'"]*/g)) - expect(checkoutStep.with['fetch-depth']).toBe(0) - expect(bumpIndex).toBeGreaterThanOrEqual(0) - // Why: the cut is the only point that advances the release ledger, so this - // tag's revision is never rebuilt later — it appends that row, nothing else. - expect(generateIndex).toBeGreaterThan(bumpIndex) - expect(bumpStep.run.indexOf('git add package.json')).toBeGreaterThan(generateIndex) - expect(stagedPaths).toEqual(['package.json', 'resources/skills/release-mapping.json']) - // Every distinct mention must be staged, so a copy, a redirect, or a path - // held in a variable cannot reach the content-addressed artifacts. Matched - // without a trailing slash so `dir="resources/skills"` still counts. - expect([...mentioned]).toEqual(stagedPaths.slice(1)) - // Regeneration is banned job-wide by the generator suite. Here: `-a`, `-am`, - // and `--all` sweep unstaged artifacts in; `--allow-empty` below must not. - expect(commands).not.toMatch(/\bcommit\b[^\n]*(?:\s-[a-z]*a[a-z]*\b|\s--all\b)/) - expect(bumpStep.run).toContain('git diff --cached --quiet') - expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') - }) - - it('keeps release-cut RC retries monotonic across stale attempts', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const versionStep = parsedWorkflow.jobs.cut.steps.find( - (step) => step.name === 'Compute next version' - ) - - expect(versionStep.run).toContain('node config/scripts/release-rc-history.mjs "$1"') - expect(versionStep.run).toContain('tag_matches_current_ref') - expect(versionStep.run).toContain('cutting the next version instead of reusing stale artifacts') - expect(versionStep.run).toContain('git rev-parse "$existing_rc_tag"') - }) - - it('bumps separate Homebrew casks for stable and RC desktop tags', () => { - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const homebrewWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/homebrew-bump.yml'), 'utf8') - ) - - expect(releaseWorkflow.jobs['homebrew-bump'].if).toContain( - "startsWith(needs.cut.outputs.tag, 'v')" - ) - expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain('-rc.') - expect(releaseWorkflow.jobs['homebrew-bump-published-rc-draft'].with.tag).toBe( - '${{ needs.cut.outputs.latest_published_rc_tag }}' - ) - - const resolveCaskStep = homebrewWorkflow.jobs['bump-cask'].steps.find( - (step) => step.name === 'Resolve cask target' - ) - const renderStep = homebrewWorkflow.jobs['bump-cask'].steps.find( - (step) => step.name === 'Render updated cask file' - ) - const copyStep = homebrewWorkflow.jobs['bump-cask'].steps.find( - (step) => step.name === 'Copy cask into tap and open PR' - ) - - expect(resolveCaskStep.run).toContain('token="orca@rc"') - expect(resolveCaskStep.run).toContain('token="orca"') - expect(renderStep.env.CASK_PATH).toBe('${{ steps.cask.outputs.path }}') - expect(copyStep.run).toContain('cp "$CASK_PATH" "tap/$CASK_PATH"') - expect(copyStep.run).toContain('git add "$CASK_PATH"') - }) - - it('installs the Electron package binary in the shared unit-test workflow', () => { - const unitTestWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8') - ) - const installStep = unitTestWorkflow.jobs.test.steps.find( - (step) => step.name === 'Install Electron package binary for tests' - ) - - expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs') - }) - - it('smokes the packaged CLI from outside the checkout in PR checks', () => { - const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') - const parsedWorkflow = parse(prWorkflow) - const smokeStep = parsedWorkflow.jobs.package.steps.find( - (step) => step.name === 'Smoke packaged CLI' - ) - - expect(smokeStep.run).toBe( - 'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked' - ) - }) - - it('keeps terminal scale perf wired to the report budget gate', () => { - const packageScripts = packageJson.scripts - const terminalPerfWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/terminal-perf.yml'), 'utf8') - ) - const steps = terminalPerfWorkflow.jobs['terminal-perf'].steps - const runStep = steps.find((step) => step.name === 'Run terminal scale perf report gate') - const uploadStep = steps.find((step) => step.name === 'Upload terminal perf report') - - expect(packageScripts['test:e2e:terminal-perf:scale:report']).toContain( - 'run-terminal-scale-perf-report-gate.mjs' - ) - expect(runStep.run).toContain('pnpm run test:e2e:terminal-perf:scale:report') - expect(runStep.run).toContain('xvfb-run --auto-servernum') - const manualProfileKnobs = [ - ['ORCA_TERMINAL_PERF_FRAME_COUNT', 'frame_count', 'ORCA_E2E_OPENCODE_FRAME_COUNT'], - [ - 'ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS', - 'frame_interval_ms', - 'ORCA_E2E_OPENCODE_FRAME_INTERVAL_MS' - ], - [ - 'ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS', - 'pressure_output_chars', - 'ORCA_E2E_OPENCODE_PRESSURE_OUTPUT_CHARS' - ], - ['ORCA_TERMINAL_PERF_SCALE_PANES', 'scale_panes', 'ORCA_E2E_OPENCODE_SCALE_PANES'], - [ - 'ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES', - 'scale_cross_workspace_panes', - 'ORCA_E2E_OPENCODE_SCALE_CROSS_WORKSPACE_PANES' - ], - [ - 'ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES', - 'scale_pressure_panes', - 'ORCA_E2E_OPENCODE_SCALE_PRESSURE_PANES' - ], - [ - 'ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES', - 'scale_hidden_pressure_panes', - 'ORCA_E2E_OPENCODE_SCALE_HIDDEN_PRESSURE_PANES' - ] - ] - for (const [workflowEnv, inputName, runnerEnv] of manualProfileKnobs) { - expect(runStep.env[workflowEnv]).toBe(`\${{ inputs.${inputName} }}`) - expect(runStep.run).toContain(runnerEnv) - } - expect(uploadStep.uses).toBe('actions/upload-artifact@v7') - expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}') - }) - - it('keeps platform golden regressions in the manual and release workflows', () => { - const packageScripts = packageJson.scripts - const goldenWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8') - ) - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const steps = goldenWorkflow.jobs['golden-e2e'].steps - const goldenPlatformLabels = new Map([ - ['linux', 'Linux'], - ['mac', 'macOS'], - ['windows', 'Windows'] - ]) - const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include - const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort() - const goldenRunSteps = new Map( - goldenPlatforms.map((platform) => { - const label = goldenPlatformLabels.get(platform) - - expect(label, platform).toBeDefined() - - return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)] - }) - ) - const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden'] - const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include - const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence'] - const releaseBuildNeeds = releaseWorkflow.jobs.build.needs - const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs - // Why: Windows release evidence is temporarily paused for CI runner PTY readiness. - const releaseEvidencePlatforms = ['linux', 'mac'] - - expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( - '@terminal-rendering-golden' - ) - expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( - 'terminal-raw-emoji-table-scroll-restore.spec.ts' - ) - expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( - 'terminal-webgl-atlas-budget.spec.ts' - ) - expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain( - 'terminal-long-table-scroll-restore.spec.ts' - ) - const goldenCommand = packageScripts['test:e2e:terminal-rendering-golden'] - expect(goldenCommand).toContain('--project electron-headless') - expect(goldenCommand).toContain('--project electron-headful') - expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( - 'golden-windows-fresh-startup.spec.ts' - ) - expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( - '@windows-fresh-startup-golden' - ) - expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( - 'golden-posix-profile-index-fsync.spec.ts' - ) - expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( - 'golden-posix-fresh-startup.spec.ts' - ) - expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( - '@posix-profile-index-golden' - ) - expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( - 'terminal-opencode-emoji-table-rendering.spec.ts' - ) - expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( - 'terminal-long-table-scroll-restore.spec.ts' - ) - expect(goldenMatrix).toEqual([ - { os: 'ubuntu-latest', platform: 'linux' }, - { os: 'macos-15', platform: 'mac' }, - { os: 'windows-2022', platform: 'windows' } - ]) - expect(goldenRunSteps.get('linux')?.run).toContain( - 'pnpm run test:e2e:terminal-rendering-golden' - ) - expect(goldenRunSteps.get('linux')?.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - expect(goldenRunSteps.get('mac')?.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - expect(goldenRunSteps.get('windows')).toMatchObject({ - if: "runner.os == 'Windows'", - shell: 'pwsh' - }) - expect(goldenRunSteps.get('windows').run).toContain( - 'pnpm run --if-present test:e2e:windows-fresh-startup-golden' - ) - expect(goldenWorkflow.on.pull_request).toBeUndefined() - expect(goldenWorkflow.on.workflow_dispatch).toBeDefined() - expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden') - expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence') - expect(publishReleaseNeeds).toContain('terminal-rendering-golden') - expect(publishReleaseNeeds).toContain('build') - expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') - expect(releaseGoldenJob['continue-on-error']).toBeUndefined() - expect(releaseGoldenMatrix).toEqual(goldenMatrix) - const releaseLinuxRunStep = releaseGoldenJob.steps.find( - (step) => step.name === 'Run terminal rendering golden on Linux' - ) - expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - expect(releaseLinuxRunStep.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - const releaseMacRunStep = releaseGoldenJob.steps.find( - (step) => step.name === 'Run terminal rendering golden on macOS' - ) - expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - expect(releaseMacRunStep.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - const releaseWindowsRunStep = releaseGoldenJob.steps.find( - (step) => step.name === 'Run fresh-startup golden on Windows' - ) - expect(releaseWindowsRunStep).toMatchObject({ - if: "runner.os == 'Windows'", - shell: 'pwsh' - }) - expect(releaseWindowsRunStep.run).toContain( - 'pnpm run --if-present test:e2e:windows-fresh-startup-golden' - ) - expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden') - expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden') - expect(releaseEvidenceJob['continue-on-error']).toBe(true) - expect( - releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() - ).toEqual(releaseEvidencePlatforms) - expect(releaseEvidenceJob.steps.map((step) => step.run ?? '')).toContain( - 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence' - ) - }) }) diff --git a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs deleted file mode 100644 index a4998760b67..00000000000 --- a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { readFileSync } from 'node:fs' -import { parse } from 'yaml' -import { describe, expect, it } from 'vitest' - -describe('packaged hang watchdog worker contract', () => { - it('boots the worker from app.asar in PR checks', () => { - const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) - const smokeSource = readFileSync( - 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', - 'utf8' - ) - const smokeStep = workflow.jobs.package.steps.find( - (step) => step.name === 'Smoke packaged hang watchdog worker' - ) - - expect(smokeStep.run).toBe( - 'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked' - ) - expect(smokeSource).toContain( - "process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]" - ) - expect(smokeSource).toContain('const LAUNCH_TIMEOUT_MS = 30_000') - expect(smokeSource).toContain('timeout: LAUNCH_TIMEOUT_MS') - }) - - // Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion. - it('fails the smoke when the packaged worker never reports success', () => { - const smokeSource = readFileSync( - 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', - 'utf8' - ) - - expect(smokeSource).toContain('app.exit(1)') - expect(smokeSource).not.toContain('app.quit()') - expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))') - }) -}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 98dc914052a..40ef234fd49 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -53,8 +53,14 @@ const GIT_COMPAT_PREFIXES = [ ] // Why narrow: the contract pins Codex's read-repair, so it runs when the heal that -// depends on it, its app-server transport, or the contract itself changes. +// depends on it, its app-server transport, or the contract itself changes. The same +// job pins --no-daemon for Orca's codex shell wrapper and the project-trust key. const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ + 'src/main/agent-trust-presets', + 'src/main/codex/config-toml-trust', + 'src/main/pty/codex-no-daemon-binary-contract', + 'src/main/pty/codex-shell-launch-preflight', + 'src/shared/codex-shell-function', 'src/main/codex/codex-index-heal-binary-contract', 'src/main/codex/codex-session-index-heal', 'src/main/codex/codex-app-server-session', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 1981623d281..6232c9a4b8e 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -153,6 +153,13 @@ describe('per-job path classification', () => { expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], { codex_index_heal_contract: true }) + for (const file of ['src/main/agent-trust-presets.ts', 'src/main/codex/config-toml-trust.ts']) { + expectClassification([file], { + codex_index_heal_contract: true, + package: true, + package_windows: true + }) + } // Keep the real-binary gate live when a transport or launch dependency changes. for (const file of [ 'src/main/codex/codex-app-server-capability-signal.ts', diff --git a/config/scripts/pr-workflow-lint-parity.test.mjs b/config/scripts/pr-workflow-lint-parity.test.mjs deleted file mode 100644 index 648714f244c..00000000000 --- a/config/scripts/pr-workflow-lint-parity.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { readFileSync } from 'node:fs' -import { parse } from 'yaml' -import { describe, expect, it } from 'vitest' - -// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure -// does not mask the rest and oxlint keeps its `--format github` annotations. -// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so -// this gate fails the moment a `lint` step has no counterpart in pr.yml. - -// Flags that only change reporting, so they must not split two otherwise identical commands. -const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter']) -const REPORTING_FLAGS = new Set(['--quiet']) -const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node']) - -function splitCommandChain(command) { - return command - .split(/\n|&&|;/) - .map((part) => part.trim()) - .filter(Boolean) -} - -function canonicalize(command) { - const tokens = command.split(/\s+/) - const canonical = [] - - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index] - if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) { - continue - } - if (REPORTING_FLAGS.has(token)) { - continue - } - if (REPORTING_FLAGS_WITH_VALUE.has(token)) { - index += 1 - continue - } - canonical.push(token) - } - - return canonical.join(' ') -} - -/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */ -function resolveLeafCommands(command, scripts, seen = new Set()) { - const leaves = [] - - for (const part of splitCommandChain(command)) { - const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1] - if (scriptName && scripts[scriptName] && !seen.has(scriptName)) { - leaves.push( - ...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName])) - ) - continue - } - leaves.push(canonicalize(part)) - } - - return leaves -} - -describe('PR workflow lint parity', () => { - it('runs every `pnpm lint` step on pull requests', () => { - const { scripts } = JSON.parse(readFileSync('package.json', 'utf8')) - const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) - - // Scan every job: which one hosts the lint steps is an organizational - // detail that has already been renamed once (verify -> static_analysis). - const workflowCommands = new Set( - Object.values(workflow.jobs) - .flatMap((job) => job.steps ?? []) - .filter((step) => typeof step.run === 'string') - .flatMap((step) => resolveLeafCommands(step.run, scripts)) - ) - - const missing = resolveLeafCommands(scripts.lint, scripts).filter( - (leaf) => !workflowCommands.has(leaf) - ) - - expect( - missing, - `.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` + - 'Add a step for each one so PR CI matches `pnpm lint`.' - ).toEqual([]) - }) -}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index dde93505193..04bbf5d489a 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -2,7 +2,6 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' import { UNIT_EXCLUDE } from './ci-unit-files.mjs' -import { mobileWebCheckArgs } from './run-mobile-web-app-checks.mjs' import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) @@ -559,12 +558,5 @@ describe('PR workflow parallelism', () => { expect(step.run).toContain('node config/scripts/run-mobile-web-app-checks.mjs') expect(step.run).not.toContain('--prepare-route-snapshot') expect(step.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV]).toBe('1') - expect(mobileWebCheckArgs).toEqual([ - 'run', - '--config', - 'config/vitest.config.ts', - 'config/scripts/mobile-web-app-', - 'config/scripts/build-mobile-web-app-bundle.test.mjs' - ]) }) }) diff --git a/config/scripts/regenerate-xterm-patches.test.mjs b/config/scripts/regenerate-xterm-patches.test.mjs index 5261d0c74cd..0d1329043de 100644 --- a/config/scripts/regenerate-xterm-patches.test.mjs +++ b/config/scripts/regenerate-xterm-patches.test.mjs @@ -98,26 +98,6 @@ const PATCHED = { } describe('pnpm diff format', () => { - it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => { - expect(PNPM_DIFF_FLAGS).toEqual([ - '-c', - 'core.safecrlf=false', - '-c', - 'core.quotePath=false', - 'diff', - '--src-prefix=a/', - '--dst-prefix=b/', - '--ignore-cr-at-eol', - '--irreversible-delete', - '--full-index', - '--no-index', - '--text', - '--no-ext-diff', - '--no-color', - '--' - ]) - }) - it('matches pnpm git config isolation', () => { const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' }) expect(environment).toMatchObject({ diff --git a/config/scripts/run-mobile-web-app-checks.mjs b/config/scripts/run-mobile-web-app-checks.mjs index c6257676911..666b7d6d68d 100644 --- a/config/scripts/run-mobile-web-app-checks.mjs +++ b/config/scripts/run-mobile-web-app-checks.mjs @@ -9,7 +9,7 @@ import { PAGE_ROUTE_MODULES } from './mobile-web-app-page-route-modules.mjs' import { spawnProcess } from './script-child-process.mjs' const require = createRequire(import.meta.url) -export const mobileWebCheckArgs = [ +const mobileWebCheckArgs = [ 'run', '--config', 'config/vitest.config.ts', diff --git a/config/scripts/run-multi-workspace-typing-bench.mjs b/config/scripts/run-multi-workspace-typing-bench.mjs index 2aae129c9b2..557453498d3 100644 --- a/config/scripts/run-multi-workspace-typing-bench.mjs +++ b/config/scripts/run-multi-workspace-typing-bench.mjs @@ -23,6 +23,11 @@ const knobByFlag = { '--keys': 'ORCA_TYPING_BENCH_KEYS', '--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS', '--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS', + '--git-churn-repos': 'ORCA_TYPING_BENCH_GIT_CHURN_REPOS', + '--git-churn-files': 'ORCA_TYPING_BENCH_GIT_CHURN_FILES', + '--git-churn-concurrency': 'ORCA_TYPING_BENCH_GIT_CHURN_CONCURRENCY', + '--codex-frame-rows': 'ORCA_TYPING_BENCH_CODEX_FRAME_ROWS', + '--codex-split-delay-ms': 'ORCA_TYPING_BENCH_CODEX_SPLIT_DELAY_MS', '--worktrees': 'ORCA_TYPING_BENCH_METADATA_WORKTREES', '--repositories': 'ORCA_TYPING_BENCH_METADATA_REPOSITORIES', '--terminal-tabs': 'ORCA_TYPING_BENCH_METADATA_TERMINAL_TABS', diff --git a/config/scripts/script-module-dependencies.test.mjs b/config/scripts/script-module-dependencies.test.mjs index 68482954ee3..117d00a4fe2 100644 --- a/config/scripts/script-module-dependencies.test.mjs +++ b/config/scripts/script-module-dependencies.test.mjs @@ -8,7 +8,6 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' @@ -108,18 +107,4 @@ describe('copyScriptWithLocalModules', () => { copyScriptWithLocalModules(join(sourceDir, 'entry.mjs'), destinationDir) expect(existsSync(join(destinationDir, 'entry.mjs'))).toBe(true) }) - - // The real tree this stages: the packaged-addon gate reaches its PE reader by - // require, so a walker that missed it would break every rebuild fixture. - it('stages the node-pty job-ownership gate with everything it requires', () => { - const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'scripts') - copyScriptWithLocalModules( - fileURLToPath(new URL('./node-pty-job-ownership.cjs', import.meta.url)), - destinationDir - ) - expect(readdirSync(destinationDir).sort()).toEqual([ - 'node-pty-job-ownership.cjs', - 'windows-pe-machine.cjs' - ]) - }) }) diff --git a/config/scripts/ssh-docker-ci-sharding.test.mjs b/config/scripts/ssh-docker-ci-sharding.test.mjs index c3b015727d6..403c059e92c 100644 --- a/config/scripts/ssh-docker-ci-sharding.test.mjs +++ b/config/scripts/ssh-docker-ci-sharding.test.mjs @@ -37,26 +37,6 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', () ]) }) -it('runs all four isolated shards and the special SSH suites exactly once', () => { - expect(job.strategy.matrix.shard).toEqual([1, 2, 3, 4]) - expect(job.strategy['fail-fast']).toBe(false) - const remaining = job.steps.find((step) => step.name === 'Run remaining Docker SSH E2E') - expect(remaining.run).toContain('pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4') - expect(remaining.if).toBe('always()') - expect(remaining['continue-on-error']).toBeUndefined() - expect(readRunner(runners[0])).toContain("'--workers=1'") - expect(job.steps.find((step) => step.name === 'Run Docker SSH watcher isolation E2E').if).toBe( - 'matrix.shard == 1' - ) - expect( - job.steps.find( - (step) => step.name === 'Run Docker SSH terminal parking + startup readiness E2E' - ).if - ).toBe('always() && matrix.shard == 1') - const upload = job.steps.find((step) => step.uses === 'actions/upload-artifact@v7') - expect(upload.with.name).toContain('${{ matrix.shard }}') -}) - it('native Playwright shards preserve every SSH test and project exactly once', async () => { const cli = join(dirname(require.resolve('playwright/package.json')), 'cli.js') const env = { diff --git a/config/scripts/terminal-ime-e2e-workflow.test.mjs b/config/scripts/terminal-ime-e2e-workflow.test.mjs index 162a6d08ace..cb12839a2af 100644 --- a/config/scripts/terminal-ime-e2e-workflow.test.mjs +++ b/config/scripts/terminal-ime-e2e-workflow.test.mjs @@ -45,29 +45,6 @@ describe('terminal IME e2e workflow', () => { expect(nativeIndex).toBeGreaterThan(deterministicIndex) }) - it('keeps IBus lifecycle scoped to owned processes', () => { - const runner = readFileSync( - join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'), - 'utf8' - ) - - expect(runner).toContain( - "['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']" - ) - expect(runner).toContain("spawn('xfwm4', ['--compositor=off']") - expect(runner).toContain("['initial-input-mode', 'hangul']") - expect(runner).toContain("['hangul-keyboard', '2']") - expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')") - expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')") - expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs') - expect(runner).toMatch( - /'test:e2e:headful',\s*'--workers=1',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/ - ) - expect(runner).not.toContain("'--replace'") - expect(runner).not.toContain('killall') - expect(runner).not.toContain('pkill') - }) - it('runs native Wayland independently with CJK fonts and retained evidence', () => { const job = workflow.jobs['linux-wayland'] expect(job.needs).toBeUndefined() @@ -82,13 +59,4 @@ describe('terminal IME e2e workflow', () => { expect(upload.if).toBe('always()') expect(upload.with.name).toBe('terminal-wayland-ime-evidence') }) - - it('bounds blocking native input commands', () => { - const nativeSpec = readFileSync( - join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'), - 'utf8' - ) - - expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3) - }) }) diff --git a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs index fedfdc2c699..3deb4d1fdbc 100644 --- a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs +++ b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs @@ -5,7 +5,7 @@ // CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every // variant is asserted to agree with the baseline before it is timed. // 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and -// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower. +// to record that an includes() introducer pre-filter makes it slower. // // Payloads are generated deterministically (LCG, no Math.random) so runs compare. // @@ -214,7 +214,7 @@ const kittyScan = (data) => { tracker.scanReplay(data) return tracker.flags } -// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47. +// The includes() introducer pre-filter a daemon-side mode scan would use. const kittyGated = (data) => { if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) { return 0 diff --git a/config/scripts/verify-dev-channel-packaging.test.mjs b/config/scripts/verify-dev-channel-packaging.test.mjs index dfde5d461d2..ceef8f76174 100644 --- a/config/scripts/verify-dev-channel-packaging.test.mjs +++ b/config/scripts/verify-dev-channel-packaging.test.mjs @@ -53,19 +53,6 @@ describe('electron-builder dev-channel identity', () => { expect(config.win.verifyUpdateCodeSignature).toBe(false) }) - // Why on every channel: the hook is the only handle electron-builder gives on - // the NSIS uninstaller, and it signs nothing — it relays the file to and from - // the CI SignPath request. Carrying it must not drag a publisherName onto a - // dev build, which is the failure the split above exists to prevent. - it('carries the uninstaller sign hook without changing publisherName semantics', () => { - for (const env of [{}, WIN_ADHOC_ENV]) { - const config = loadConfigWithEnv(env) - expect(typeof config.win.signtoolOptions.sign).toBe('function') - } - expect(loadConfigWithEnv({}).win.signtoolOptions.publisherName).toBe('SignPath Foundation') - expect(loadConfigWithEnv(WIN_ADHOC_ENV).win.signtoolOptions.publisherName).toBeUndefined() - }) - it.each([ ['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'], ['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'], diff --git a/config/scripts/win-crash-survival-e2e.test.mjs b/config/scripts/win-crash-survival-e2e.test.mjs index 40a2e1bb483..9c772eb01eb 100644 --- a/config/scripts/win-crash-survival-e2e.test.mjs +++ b/config/scripts/win-crash-survival-e2e.test.mjs @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { parseArgs } from '../../tests/tools/win-crash-survival-e2e/cli-args.mjs' import { buildCrashAssertions } from '../../tests/tools/win-crash-survival-e2e/crash-assertions.mjs' @@ -13,26 +12,6 @@ import { closeApp, resolveElectronMainPid } from '../../tests/tools/win-update-e import { isPidAlive } from '../../tests/tools/win-update-e2e/daemon-processes.mjs' describe('win-crash-survival-e2e proof contracts', () => { - it('keeps the packaged proof manually dispatchable without a PR trigger', () => { - const workflow = readFileSync('.github/workflows/win-crash-survival-e2e.yml', 'utf8') - expect(workflow).not.toMatch(/^ pull_request:/m) - expect(workflow).toMatch(/^ workflow_dispatch:/m) - expect(workflow).not.toMatch(/^ push:/m) - expect(workflow).toContain('--expect "$env:EXPECT"') - expect(workflow).toContain('exit $LASTEXITCODE') - expect(workflow).toContain("'!config/**/*.test.*'") - expect(workflow).toContain("'!src/**/*.test.*'") - expect(workflow).toContain("'!src/**/*.bench.*'") - expect(workflow).toContain("'!config/reliability-gates.jsonc'") - expect(workflow).toContain("'resources/**'") - expect(workflow).toContain('cache: pnpm') - expect(workflow.indexOf('- name: Setup Node.js')).toBeGreaterThan( - workflow.indexOf('- name: Setup pnpm') - ) - expect(workflow).toContain("if: steps.cache-installer.outputs.cache-hit != 'true'") - expect(workflow).toContain('crash-survival-electron-builder-') - }) - it('requires the full survival oracle, including daemon identity and reattach', () => { const base = { profile: 'survival', @@ -58,15 +37,6 @@ describe('win-crash-survival-e2e proof contracts', () => { ).toBe(false) }) - it('scans for FailFast only after the post-crash input probe', () => { - const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8') - const scanIndex = harness.indexOf('const { events: failFastEvents }') - const probeIndex = harness.indexOf('reattachProven = await proveReattachedShell') - expect(scanIndex).not.toBe(-1) - expect(probeIndex).not.toBe(-1) - expect(scanIndex).toBeGreaterThan(probeIndex) - }) - it('fails closed when the Windows event log query fails', () => { let command = '' expect(() => @@ -162,11 +132,7 @@ describe('win-crash-survival-e2e proof contracts', () => { expect(reattachSentinelMatches('1660|canary|extra', 'canary', 1660)).toBe(false) }) - it('requires the real packaged main for the crash proof but permits fallback cleanup', async () => { - const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8') - expect(harness).toContain( - 'resolveElectronMainPid(session.app, { allowLauncherFallback: false })' - ) + it('resolves the real packaged main but permits fallback cleanup', async () => { expect( await resolveElectronMainPid({ evaluate: async () => 222, diff --git a/config/scripts/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs index f1d3742e583..0f8a651e12f 100644 --- a/config/scripts/windows-process-tree-gyp-path.test.mjs +++ b/config/scripts/windows-process-tree-gyp-path.test.mjs @@ -1,43 +1,13 @@ import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { isAbsolute, join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' const projectDir = resolve(import.meta.dirname, '../..') -const PATCH = readFileSync( - join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch'), - 'utf8' -) const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')" describe('windows-process-tree node-addon-api gyp path', () => { - it('stages headers without a pnpm-sensitive gyp dependency', () => { - expect(PATCH).not.toContain('+ "../../node-addon-api') - expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],') - expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],') - const buildScript = readFileSync( - join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'), - 'utf8' - ) - expect(buildScript).toContain('stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)') - expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.') - const rebuildHelper = readFileSync( - join(projectDir, 'config/scripts/windows-process-tree-gyp-rebuild.mjs'), - 'utf8' - ) - expect(rebuildHelper).toContain("createRequire(join(packageDir, 'package.json'))") - expect(rebuildHelper).toContain("resolve('node-addon-api/package.json')") - expect(rebuildHelper).toContain("'napi.h'") - expect(rebuildHelper).toContain("'napi-inl.h'") - expect(rebuildHelper).toContain("'napi-inl.deprecated.h'") - const rebuildScript = readFileSync( - join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), - 'utf8' - ) - expect(rebuildScript).toContain('stageWindowsProcessTreeNodeAddonApiHeaders()') - }) - // The installed Windows dependency is exercised by the Windows CI lane. it.runIf(process.platform === 'win32')( 'resolves node_addon_api.gyp to a real file from the package directory', diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs deleted file mode 100644 index e7b18363aeb..00000000000 --- a/config/scripts/windows-pty-native-capability-workflow.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' - -const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) - -describe('packaged Windows PTY native capability routing', () => { - it('runs the packaged executable immediately after the unpacked app is built', () => { - const job = workflow.jobs.package_windows - const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app') - const smokeIndex = job.steps.findIndex( - (step) => step.name === 'Smoke packaged Windows PTY native capability' - ) - const smoke = job.steps[smokeIndex] - - expect(job['runs-on']).toBe('windows-2022') - expect(smokeIndex).toBe(packageIndex + 1) - expect(smoke.run).toBe( - 'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe' - ) - expect(smoke.if).toBeUndefined() - expect(smoke['continue-on-error']).toBeUndefined() - expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe( - 'node tests/tools/windows-pty-native-capability-smoke/run.mjs' - ) - }) - - it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => { - const job = workflow.jobs.package_windows - const install = job.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const nodeCacheSave = job.steps.find( - (step) => step.name === 'Save compiled Node native modules' - ) - const electronCache = job.steps.find( - (step) => step.name === 'Restore compiled Electron native modules' - ) - const build = job.steps.find((step) => step.name === 'Build package inputs') - const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime') - const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') - const verify = workflow.jobs.verify.steps.find( - (step) => step.name === 'Require successful checks' - ) - const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8') - - expect(install.with['native-runtime']).toBe('node') - expect(install.with['persist-native-cache']).toBe('false') - expect(nodeCacheSave.uses).toBe('actions/cache/save@v5') - expect(nodeCacheSave.with.key).toContain('-node-node') - expect(electronCache.with.key).toContain('-electron-node') - for (const cache of [nodeCacheSave, electronCache]) { - expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml') - expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs') - expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs') - } - expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']") - expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')") - expect(build.run).toBe('pnpm run build:release:parallel') - expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') - expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') - expect(workflow.jobs.verify.needs).toContain('package_windows') - expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') - expect(verify.run).toContain('"$PACKAGE_WINDOWS"') - }) - - it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => { - const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8') - const probe = readFileSync( - 'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs', - 'utf8' - ) - const source = `${driver}\n${probe}` - - expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'") - expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i) - expect(source).not.toContain('maxRetries') - expect(source).not.toContain('retryDelay') - expect(source).not.toContain("from 'node:child_process'") - expect(source).not.toContain("require('node:child_process')") - expect(probe).toContain("'System32', 'wscript.exe'") - expect(probe).toContain('real-orca-detached-launcher.vbs') - expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i) - expect(probe).toContain('native.terminateJob(target._pty, target.pid)') - }) -}) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs deleted file mode 100644 index 98d0d5e8e88..00000000000 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ /dev/null @@ -1,498 +0,0 @@ -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' - -const projectDir = resolve(import.meta.dirname, '../..') - -const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) - -describe('Windows signing workflow contract', () => { - it('preflights SignPath module install before Windows signing side effects', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const installStepIndexes = stepNames.flatMap((name, index) => - name === 'Install SignPath PowerShell module' ? [index] : [] - ) - const buildIndex = stepNames.indexOf('Build Windows release artifacts') - const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') - const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') - const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') - - expect(verifyNodePtyIndex).toBe(buildIndex + 1) - expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) - expect(installStepIndexes[0]).toBeLessThan(uploadIndex) - - expect(steps[verifyNodePtyIndex].run).toContain( - 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - ) - expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') - - const uploadThroughDownloadScript = steps - .slice(uploadIndex, downloadIndex + 1) - .map((step) => step.run ?? '') - .join('\n') - - expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') - - const installStep = steps[installStepIndexes[0]] - - expect(installStep.if).toBe("matrix.platform == 'win' && github.run_attempt == 1") - expect(installStep.uses).toBe('./.github/actions/install-signpath-module') - expect(installStep.run).toBeUndefined() - - const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') - const actionStep = installAction.runs.steps[0] - const installRun = actionStep.run - const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( - ([, seconds]) => seconds - ) - - expect(installAction.runs.using).toBe('composite') - expect(actionStep.shell).toBe('pwsh') - expect(installRun).toContain( - 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' - ) - expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') - expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') - expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) - expect(installRun).toContain( - "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" - ) - expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) - expect(sleepSeconds).toContain('15') - expect(sleepSeconds).toContain('30') - expect(installRun).toContain( - 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' - ) - expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop') - expect(installRun).toContain( - 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' - ) - expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') - expect(installRun).not.toContain('SignPath*') - expect(installRun).not.toMatch(/throw\s+\$_/) - }) - - it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => { - const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') - const installRun = installAction.runs.steps[0].run - - // Why: the gallery API 403s during Azure Front Door incidents while its CDN - // stays up, so a pinned nupkg is the fallback. The hash pin is the only - // integrity check on that route — losing it would let any payload install. - const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs - expect(version.default).toMatch(/^4\.\d+\.\d+$/) - expect(sha256.default).toMatch(/^[0-9a-f]{64}$/) - expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256') - expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') - expect(installRun).toContain('throw "SHA-256 mismatch for $source') - expect(installRun).toContain( - 'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg' - ) - - // The module only resolves by name when the folder matches its ModuleVersion. - expect(installRun).toContain( - '$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version' - ) - // The fallback only runs after the gallery route is exhausted, and still - // fails the job when neither route produced a usable module. - expect(installRun.indexOf('$installed = $true')).toBeLessThan( - installRun.indexOf('if (-not $installed)') - ) - expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') - }) - - it('still installs SignPath when the cut ref predates the composite action', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const checkoutIndex = stepNames.indexOf('Checkout') - const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref') - const installIndex = stepNames.indexOf('Install SignPath PowerShell module') - - // Why: the build job checks out the cut tag, which for a hotfix cut from an - // older ref can predate `.github/actions/install-signpath-module`; without - // this restore the `uses: ./…` step dies on a missing action.yml. - expect(restoreIndex).toBeGreaterThan(checkoutIndex) - expect(restoreIndex).toBeLessThan(installIndex) - - const restoreStep = steps[restoreIndex] - const restoreRun = restoreStep.run - - expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}') - expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml') - expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"') - expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions') - - // Why: restoring the action must not turn signing into a soft dependency — - // a missing module still has to fail the Windows job, and the CDN fallback - // still has to reject an unexpected payload. - expect(steps[installIndex]['continue-on-error']).toBeUndefined() - expect(restoreStep['continue-on-error']).toBeUndefined() - - const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs - .steps[0].run - - expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') - expect(installRun).toContain('throw "SHA-256 mismatch for $source') - }) - - it('never recreates Windows signing requests on a workflow rerun', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const skipStep = steps.find((step) => step.name === 'Skip Windows artifact rebuild on rerun') - - expect(skipStep?.if).toBe("matrix.platform == 'win' && github.run_attempt != 1") - expect(skipStep?.run).toContain('Existing signed release assets must be reused') - - const signingStepNames = [ - 'Build Windows release artifacts', - 'Stage unsigned inner PE files for signing', - 'Upload unsigned inner binaries for SignPath', - 'Submit inner binaries signing request', - 'Download signed inner binaries from SignPath', - 'Upload unsigned Windows installer for SignPath', - 'Submit Windows installer signing request', - 'Download signed Windows installer from SignPath', - 'Stage signed Windows release assets', - 'Publish signed Windows release artifacts' - ] - - for (const stepName of signingStepNames) { - const step = steps[stepNames.indexOf(stepName)] - expect(step?.if, stepName).toContain('github.run_attempt == 1') - } - }) - - it('shares one SignPath module install path between release and rehearsal', () => { - const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') - const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) - const installIndex = stepNames.indexOf('Install SignPath PowerShell module') - - // Why: the rehearsal exists to prove the real signing flow, so it must - // install the module exactly the way the release job does. - expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe( - './.github/actions/install-signpath-module' - ) - expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined() - expect(installIndex).toBeLessThan( - stepNames.indexOf('Download signed inner binaries from SignPath') - ) - }) - - it('requires Windows inner binary signatures before publishing', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') - const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') - const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') - const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') - - expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) - expect(stepNames[innerVerifyIndex + 1]).toBe('Notify Slack when Windows signing fails') - expect(evidenceIndex).toBe(innerVerifyIndex + 2) - expect(publishIndex).toBe(evidenceIndex + 1) - - expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('true') - expect(steps[innerVerifyIndex].run).toContain("@('Orca.exe', 'resources\\bin\\orca.exe')") - expect(stepNames).not.toContain('Roll back to original installer after failed rebuild') - - const innerChainStepNames = [ - 'Stage unsigned inner PE files for signing', - 'Upload unsigned inner binaries for SignPath', - 'Submit inner binaries signing request', - 'Download signed inner binaries from SignPath', - 'Restore signed inner binaries into unpacked app', - 'Restore signed uninstaller for the installer rebuild', - 'Replace cached elevate.exe with the signed copy', - 'Rebuild NSIS installer from signed unpacked app' - ] - for (const stepName of innerChainStepNames) { - const step = steps[stepNames.indexOf(stepName)] - expect(step, stepName).toBeDefined() - expect(step['continue-on-error'], stepName).toBeUndefined() - } - }) -}) - -// Why these exist: the NSIS uninstaller is generated inside electron-builder's -// uninstaller pass and deleted immediately after being embedded, so the only way -// CI can sign it is the export/import relay through win.signtoolOptions.sign. -// Every link is asserted here the way Orca.exe and conpty_console_list.node are. -describe('Windows NSIS uninstaller signing', () => { - const releaseSteps = () => readWorkflow('.github/workflows/release-cut.yml').jobs.build.steps - const stepNamed = (steps, name) => steps.find((step) => step.name === name) - - const EXPORT_ENV = 'ORCA_WIN_UNINSTALLER_EXPORT_PATH' - const SIGNED_ENV = 'ORCA_WIN_UNINSTALLER_SIGNED_PATH' - - it('exports the uninstaller from the first Windows build', () => { - const build = stepNamed(releaseSteps(), 'Build Windows release artifacts') - - expect(build.env[EXPORT_ENV]).toContain('uninstaller-signing') - expect(build.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') - }) - - // Why this is a test and not a comment: `files` in the electron-builder config - // is all-negation, so app-builder packs whatever is left in the checkout root. - // These steps retry, and a retried attempt would pack an unsigned .exe into - // app.asar — the very defect this chain removes. Every relay path must live - // outside the checkout. - it('keeps every relay path out of the packed checkout', () => { - const relayEnvValues = [ - ...releaseSteps(), - ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps - ].flatMap((step) => [step.env?.[EXPORT_ENV], step.env?.[SIGNED_ENV]].filter(Boolean)) - - expect(relayEnvValues.length).toBe(4) - for (const value of relayEnvValues) { - expect(value).toContain('runner.temp') - expect(value).not.toContain('github.workspace') - } - - const relayScripts = [ - ...releaseSteps(), - ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps - ] - .map((step) => step.run ?? '') - .filter((run) => run.includes('uninstaller-signing')) - - expect(relayScripts.length).toBeGreaterThan(0) - for (const run of relayScripts) { - // Why count occurrences rather than assert `toContain` once: a step - // carrying two relay paths could root the first in RUNNER_TEMP and leave - // the second bare-relative — which resolves against the checkout, and is - // exactly the shape of the defect this test exists to catch. - const mentions = run.match(/uninstaller-signing/g) ?? [] - const rooted = run.match(/Join-Path \$env:RUNNER_TEMP 'uninstaller-signing/g) ?? [] - - expect(rooted.length, run).toBe(mentions.length) - expect(run).not.toContain('$env:GITHUB_WORKSPACE') - } - }) - - it('stages the uninstaller into the same request as the inner binaries', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - - expect(stage.run).toContain('uninstaller-signing\\unsigned\\orca-uninstaller.exe') - expect(stage.run).toContain('uninstaller\\orca-uninstaller.exe') - // No third SignPath request: exactly two submissions, as budgeted for the - // 1h + 4h approval waits inside the 360-minute job cap. - const submissions = releaseSteps().filter( - (step) => step.uses === 'signpath/github-action-submit-signing-request@v2' - ) - expect(submissions).toHaveLength(2) - }) - - it('keeps the uninstaller out of the inner-binary copy-back list', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - const restoreInner = stepNamed( - releaseSteps(), - 'Restore signed inner binaries into unpacked app' - ) - - expect(stage.run).not.toMatch(/\$list\.Add\(['"]uninstaller/) - expect(restoreInner.run).not.toContain('orca-uninstaller.exe') - }) - - it('blocks publication when the exported uninstaller is missing', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - const uninstallerBlock = stage.run.slice(stage.run.indexOf('$exportedUninstaller')) - - expect(uninstallerBlock).toContain('throw "No exported NSIS uninstaller') - expect(uninstallerBlock).not.toContain('catch') - expect(uninstallerBlock).toMatch(/Copy-Item[^\r\n]*-ErrorAction Stop/) - }) - - it('restores exact artifact paths rather than suffix-matching another executable', () => { - for (const [name, path] of [ - ['Restore signed inner binaries into unpacked app', '$relative'], - [ - 'Restore signed uninstaller for the installer rebuild', - "'uninstaller\\orca-uninstaller.exe'" - ] - ]) { - const restore = stepNamed(releaseSteps(), name) - expect(restore.run).toContain(`(Join-Path 'signed-inner' ${path})`) - expect(restore.run).toContain(`(Join-Path 'signed-inner/signing-stage' ${path})`) - expect(restore.run).toContain('if (@($candidates).Count -ne 1)') - expect(restore.run).not.toContain('Select-Object -First 1') - expect(restore.run).not.toContain('-like "*$relative"') - const rehearsal = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') - const rehearseRestore = stepNamed(rehearsal.jobs.rehearse.steps, name) - expect(rehearseRestore.run).toBe(restore.run) - expect(restore.env.SIGNING_POLICY).toBe('release-signing') - expect(rehearseRestore.env.SIGNING_POLICY).toBe( - "${{ inputs.signing-policy-slug || 'test-signing' }}" - ) - expect(restore.run).toContain("$requireValid = $env:SIGNING_POLICY -ne 'test-signing'") - expect(restore.run).toContain( - "if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*')))" - ) - } - }) - - it('requires the CLI signature at its electron-builder extraResources destination', () => { - const require = createRequire(import.meta.url) - const config = require('../electron-builder.config.cjs') - const cli = config.win.extraResources.find((resource) => resource.to === 'bin/orca.exe') - expect(cli).toBeDefined() - const payloadPath = `resources/${cli.to}`.replaceAll('/', '\\') - const rehearsal = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') - for (const gate of [ - stepNamed(releaseSteps(), 'Verify Windows inner binary signatures'), - stepNamed(rehearsal.jobs.rehearse.steps, 'Verify signatures end to end') - ]) { - expect(gate.run).toContain(`foreach ($requiredTarget in @('Orca.exe', '${payloadPath}'))`) - expect(gate.run).toContain( - 'if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }' - ) - expect(gate.run).toContain('foreach ($relative in $targets)') - } - }) - - it('waits for approval even when the notification fails', () => { - const steps = releaseSteps() - const notify = stepNamed( - steps, - 'Notify Slack that inner-binary signing is waiting for approval' - ) - const download = stepNamed(steps, 'Download signed inner binaries from SignPath') - expect(notify['continue-on-error']).toBe(true) - expect(download.if).not.toContain('notify-inner-signing') - expect(download.if).toContain("steps.submit-inner-signing.outcome == 'success'") - }) - - it('re-injects the signed uninstaller into the rebuilt installer', () => { - const steps = releaseSteps() - const restore = stepNamed(steps, 'Restore signed uninstaller for the installer rebuild') - const rebuild = stepNamed(steps, 'Rebuild NSIS installer from signed unpacked app') - const names = steps.map((step) => step.name) - - expect(restore.if).toContain('github.run_attempt == 1') - expect(restore.if).toContain("steps.restore-signed-inner.outcome == 'success'") - expect(restore.run).toContain('orca-uninstaller.exe') - expect(names.indexOf(restore.name)).toBeLessThan(names.indexOf(rebuild.name)) - expect(rebuild.env[SIGNED_ENV]).toContain('uninstaller-signing') - // Default success() gating blocks the rebuild after a failed restore. - expect(rebuild.if).not.toContain('restore-signed-uninstaller') - }) - - // NSIS hides the uninstaller in a compressed data section the bundled 7za - // cannot read, so the gate proves it from the sign hook's digest receipt - // instead of extracting it — and only when the relay actually ran. - it('reports the embedded uninstaller in the inner-binary evidence gate', () => { - const gate = stepNamed(releaseSteps(), 'Verify Windows inner binary signatures') - - expect(gate.env.UNINSTALLER_SIGNING_COMPLETED).toBe( - "${{ steps.restore-signed-uninstaller.outcome == 'success' }}" - ) - expect(gate.run).toContain('.embedded-sha256') - expect(gate.run).toContain("$failures.Add('The NSIS uninstaller signing did not complete.')") - expect(gate.run).toContain("$env:UNINSTALLER_SIGNING_COMPLETED -eq 'true'") - expect(gate.run).toContain('not signed by SignPath Foundation: Uninstall Orca.exe') - // The uninstaller must not join the 7z payload loop, which cannot see it. - expect(gate.run).not.toContain("$targets += 'Uninstall Orca.exe'") - }) - - it('rehearses the uninstaller leg end to end', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const names = steps.map((step) => step.name) - const pack = stepNamed(steps, 'Package Windows app and export the NSIS uninstaller') - const rebuild = stepNamed(steps, 'Build NSIS installer from signed unpacked app') - const verify = stepNamed(steps, 'Verify signatures end to end') - - // --dir never produces an uninstaller, so the rehearsal has to build the - // installer the way release-cut's first Windows pass does. - expect(pack.run).toContain('--win --publish never') - expect(pack.run).not.toContain('--dir') - expect(pack.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') - expect(names).toContain('Restore signed uninstaller for the installer rebuild') - expect(rebuild.env[SIGNED_ENV]).toContain('orca-uninstaller.exe') - expect(verify.run).toContain('.embedded-sha256') - // The receipt only proves the import leg ran. The rehearsal is where the - // shipped uninstaller itself gets checked — the release job cannot install - // onto the runner it publishes from. - expect(verify.run).toContain('shipped: Uninstall Orca.exe') - expect(verify.run).toContain('-tnsis') - expect(verify.run).toContain("-ArgumentList '/S'") - }) - - // This workflow is the merge gate, so it must not be able to fail on its own - // artefact: 7-Zip's NSIS handler is unreliable enough that its output has to - // be corroborated before a signature verdict is drawn from it. - it('never lets an unreliable extract fail the rehearsal', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const verify = stepNamed(steps, 'Verify signatures end to end') - - // The 7-Zip route is only trusted when it reproduces the relayed bytes; - // otherwise it falls through to the install route rather than failing. - expect(verify.run).toContain( - 'Write-Host "7-Zip\'s NSIS output did not match the relayed digest; falling back to a silent install."' - ) - expect(verify.run).toMatch(/\$installedUninstaller = \$null\r?\n\s*\}/) - - // The comparison that is not tautological: a file NSIS wrote out, against - // the digest the sign hook recorded. - expect(verify.run).toContain('$shippedDigest -ne $expectedDigest') - expect(verify.run).toContain('the uninstaller the installer ships is not the relayed one') - - // An installer that prompts must not hang to the 360-minute job cap, and - // the app it launches must not outlive the step holding install-dir handles. - expect(verify.run).toContain('-PassThru') - expect(verify.run).toContain('$installerProcess.WaitForExit(300000)') - expect(verify.run).toContain('the silent install did not exit within 5 minutes') - expect(verify.run).toMatch(/for \(\$attempt = 0; \$attempt -lt 20; \$attempt\+\+\)/) - expect(verify.run).toContain("Get-Process -Name 'orca-terminal-daemon'") - }) - - // resources\elevate.exe is downgraded to advisory because app-builder-lib's - // CopyElevateHelper clobbers it on every nsis pack — a pre-existing defect - // that predates the uninstaller relay and is being tracked separately. The - // escape hatch it needed is the kind that quietly grows until the gate - // asserts nothing, so pin it to exactly that one file. - it('confines the advisory escape hatch to elevate.exe', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const verify = stepNamed(steps, 'Verify signatures end to end') - const advisoryCalls = verify.run - .split('\n') - .filter((line) => line.includes('-Advisory') && line.includes('Test-Signature')) - - expect(advisoryCalls).toHaveLength(1) - expect(advisoryCalls[0]).toContain('installed: $relative') - expect(verify.run).toContain("if ($relative -eq 'resources\\elevate.exe')") - - // Both uninstaller verdicts stay fatal — the whole point of the gate. - for (const call of ['relayed: orca-uninstaller.exe', 'shipped: Uninstall Orca.exe']) { - const line = verify.run - .split('\n') - .find((it) => it.includes(`Test-Signature`) && it.includes(call)) - expect(line, call).toBeDefined() - expect(line, call).not.toContain('-Advisory') - } - - // An advisory must still reach the evidence artifact, or downgrading it - // becomes indistinguishable from deleting the check. - expect(verify.run).toContain('ADVISORY (known pre-existing') - expect(verify.run).toContain('$script:advisories.Add($problem)') - }) - - it('wires the electron-builder sign hook that the relay depends on', () => { - const require = createRequire(import.meta.url) - const configPath = resolve(projectDir, 'config/electron-builder.config.cjs') - delete require.cache[require.resolve(configPath)] - const config = require(configPath) - - expect(typeof config.win.signtoolOptions.sign).toBe('function') - delete require.cache[require.resolve(configPath)] - }) -}) diff --git a/config/scripts/xterm-image-wasm-budget-contract.test.mjs b/config/scripts/xterm-image-wasm-budget-contract.test.mjs new file mode 100644 index 00000000000..9ec5f4fc750 --- /dev/null +++ b/config/scripts/xterm-image-wasm-budget-contract.test.mjs @@ -0,0 +1,160 @@ +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const { Terminal } = require('@xterm/xterm') +const { ImageAddon } = require('@xterm/addon-image') + +// V8 caps live wasm memories per renderer (~124 under Electron's sandbox), so +// these contracts pin that idle terminals hold none and that exhaustion drops +// an image instead of wedging the terminal's write queue. + +const SIXEL_RED_REGISTER_1 = '\x1bPq#1;2;100;0;0#1~\x1b\\' +const SIXEL_USE_REGISTER_1 = '\x1bPq#1~\x1b\\' +const PNG_1X1 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' +const IIP_PNG = `\x1b]1337;File=inline=1;size=${Buffer.from(PNG_1X1, 'base64').length}:${PNG_1X1}\x07` + +class TrackedBitmap { + width = 1 + height = 1 + close = vi.fn() +} + +function stubCanvasGlobals() { + const painted = [] + vi.stubGlobal('ImageBitmap', TrackedBitmap) + vi.stubGlobal('window', { ImageBitmap: TrackedBitmap }) + vi.stubGlobal('createImageBitmap', async () => new TrackedBitmap()) + vi.stubGlobal( + 'ImageData', + class { + constructor(data, width, height) { + Object.assign(this, { data: new Uint8ClampedArray(data), width, height }) + } + } + ) + vi.stubGlobal('document', { + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ putImageData: (image) => painted.push(image) }) + }) + }) + return painted +} + +function createTerminal(options = {}) { + const terminal = new Terminal({ allowProposedApi: true }) + const addon = new ImageAddon({ + enableSizeReports: false, + storageLimit: 32, + kittySizeLimit: 8 * 1024 * 1024, + ...options + }) + terminal.loadAddon(addon) + return { + terminal, + addon, + sixel: addon._handlers.get('sixel'), + iip: addon._handlers.get('iip') + } +} + +function write(terminal, data) { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +// A throw out of a handler leaves xterm's write queue waiting forever. +function writeOrWedge(terminal, data) { + return Promise.race([ + write(terminal, data).then(() => 'parsed'), + new Promise((resolve) => setTimeout(() => resolve('wedged'), 2000)) + ]) +} + +function firstPixel(image) { + return Array.from(image.data.subarray(0, 4)) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('xterm image wasm budget', () => { + it('keeps idle terminals free of wasm decoders', async () => { + const panes = Array.from({ length: 300 }, () => createTerminal()) + try { + // Let any asynchronous decoder instantiation settle. + await new Promise((resolve) => setTimeout(resolve, 100)) + for (const { sixel, iip } of panes) { + expect(sixel._dec).toBeUndefined() + expect(iip._dec._inst).toBeNull() + expect(iip._qoiDec._inst).toBeFalsy() + } + } finally { + for (const { terminal } of panes) { + terminal.dispose() + } + } + }) + + it('returns decoders after each SIXEL and IIP image', async () => { + const painted = stubCanvasGlobals() + const { terminal, addon, sixel, iip } = createTerminal() + let added = 0 + addon.onImageAdded(() => added++) + try { + await write(terminal, SIXEL_RED_REGISTER_1) + expect(painted).toHaveLength(1) + expect(sixel._dec).toBeUndefined() + + await write(terminal, IIP_PNG) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(iip._dec._inst).toBeNull() + expect(added).toBe(2) + } finally { + terminal.dispose() + } + }) + + it('keeps SIXEL color registers across images until a terminal reset', async () => { + const painted = stubCanvasGlobals() + const { terminal } = createTerminal() + try { + await write(terminal, SIXEL_RED_REGISTER_1) + await write(terminal, SIXEL_USE_REGISTER_1) + expect(firstPixel(painted[1])).toEqual([255, 0, 0, 255]) + + await write(terminal, '\x1bc') + await write(terminal, SIXEL_USE_REGISTER_1) + expect(firstPixel(painted[2])).not.toEqual([255, 0, 0, 255]) + } finally { + terminal.dispose() + } + }) + + it('drops images without wedging the parser when wasm memory is exhausted', async () => { + stubCanvasGlobals() + // A pixelLimit no other test uses keeps the shared SIXEL pool empty for this key. + const { terminal, addon } = createTerminal({ pixelLimit: 1234567 }) + const replies = [] + terminal.onData((data) => replies.push(data)) + function exhausted() { + throw new RangeError('WebAssembly.Memory(): could not allocate memory') + } + vi.stubGlobal('WebAssembly', { ...WebAssembly, Memory: exhausted, Instance: exhausted }) + try { + expect(await writeOrWedge(terminal, '\x1b_Ga=T,f=32,s=1,v=1,i=7;AAAA/w==\x1b\\')).toBe( + 'parsed' + ) + expect(await writeOrWedge(terminal, IIP_PNG)).toBe('parsed') + expect(await writeOrWedge(terminal, SIXEL_USE_REGISTER_1)).toBe('parsed') + expect(await writeOrWedge(terminal, 'after')).toBe('parsed') + + expect(addon._storage._images.size).toBe(0) + expect(replies.join('')).toContain('ENOMEM') + expect(terminal.buffer.active.getLine(0).translateToString(true)).toContain('after') + } finally { + terminal.dispose() + } + }) +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index af45e38a72c..a38a95a2da4 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 83m + + downloads: 84m @@ -15,7 +15,7 @@ downloads downloads - 83m - 83m + 84m + 84m diff --git a/docs/reference/qoder-integration.md b/docs/reference/qoder-integration.md index 3a3a90121cd..5aa5c55147e 100644 --- a/docs/reference/qoder-integration.md +++ b/docs/reference/qoder-integration.md @@ -45,9 +45,10 @@ unverified because the available account has exhausted its credits. Hook event mapping for these paths follows the official documentation. The China executable `qoderclicn` is not registered; it was not available for verification. -New Tab follows the existing manual-launch trust behavior: an untrusted folder -can show Qoder's trust dialog. Automated workspace/draft launch paths run the -trust preflight before prompt delivery. +Every launch Orca starts (New Tab, workspace and draft launches, automations) +pre-trusts the workspace at spawn while the agent-wide "Trust the folder when +Orca starts an agent" setting is on. A hand-typed `qodercli` in a plain terminal +can still show Qoder's trust dialog. ## Sources diff --git a/docs/reference/xterm-patch-regeneration.md b/docs/reference/xterm-patch-regeneration.md index d398f10a6bb..3197de33f20 100644 --- a/docs/reference/xterm-patch-regeneration.md +++ b/docs/reference/xterm-patch-regeneration.md @@ -48,6 +48,15 @@ after reset, disable or disposal. `config/scripts/xterm-image-lifecycle-contract exercises those boundaries against the installed addon. Font zoom scales visible tiles without creating enlarged full-image canvases; `config/scripts/xterm-image-resize-contract.test.mjs` checks allocation and tile mapping. +Every wasm memory reserves its guard region inside V8's sandbox, which caps a +renderer at about 124 live memories no matter how much RAM is free. Upstream +gave each terminal a SIXEL decoder at activation and kept IIP decoders after the +first image, so a window with ~120+ terminals ran out. The patch borrows SIXEL +decoders from a small shared pool only while a sequence is open, keeping color +registers on the terminal, and drops IIP decoders after each image. A decoder +that cannot be allocated drops that image; before the patch it threw out of the +parser and left the terminal's write queue stuck. +`config/scripts/xterm-image-wasm-budget-contract.test.mjs` covers both. ## Rules diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 87156caf6e4..221ac77b69f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -196,13 +196,20 @@ function createMainBootstrapPlugin() { } } +/** + * Diagnostic escape hatch: an unminified main bundle so a V8 CPU profile of the + * main process attributes self time to real function names. Release builds never + * set this, and `pnpm build` does not read it. + */ +const MAIN_MINIFY: 'oxc' | false = process.env.ORCA_UNMINIFIED_MAIN === '1' ? false : 'oxc' + export const electronViteConfig: UserConfig = { main: { build: { // Why: 'esbuild' makes rolldown disable its own minifier and re-print every // chunk through esbuild, which is undeclared here and only resolves via // pnpm hoisting. 'oxc' is rolldown's in-process minifier. - minify: 'oxc', + minify: MAIN_MINIFY, // Why: 'hidden' emits .js.map with no sourceMappingURL, so the shipped // bundle never references maps that packaging strips out. Release CI // uploads them so minified crash traces stay decodable. diff --git a/mobile/README.md b/mobile/README.md index be17a867702..c27b22e11fd 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -180,7 +180,7 @@ Do **not** bump for additive changes: Set `MIN_COMPATIBLE_MOBILE_VERSION` (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for `MIN_COMPATIBLE_DESKTOP_VERSION` from the mobile side. -When a verdict is `blocked`, `mobile/src/components/ProtocolBlockScreen.tsx` renders a screen pointing the user at either the App Store (mobile too old) or GitHub Releases (desktop too old). +When a verdict is `blocked`, `mobile/src/components/ProtocolBlockScreen.tsx` renders a screen pointing the user at the update that clears it. When mobile is too old it opens the newest release if the installed app's update check knows one, otherwise the App Store (iOS) or GitHub Releases (Android). When desktop is too old it opens GitHub Releases. To exercise the block screen locally: set `MIN_COMPATIBLE_DESKTOP_VERSION = 999` in `mobile/src/transport/protocol-version.ts`, rebuild, pair to any desktop. Revert before merging. diff --git a/mobile/package.json b/mobile/package.json index 13724e1c57a..d973a066ec2 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", "check:tests-typecheck": "node scripts/check-tests-typecheck-ratchet.mjs", + "rpc:record": "tsx scripts/rpc-recording.mts", + "rpc:diff": "tsx scripts/rpc-diff.mts", "lint": "oxlint", "format": "oxfmt --write .", "format:check": "oxfmt --check .", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index 05d48255545..23aaf96c667 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-scan", "family": "aiVault.history", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", - "scenarioSha256": "0431ac82cbb8c60b16f4432fd0da7cff665485d668e512b20fc1168ec63db3fe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3719c54df702": { "name": "aiVault.listSessions#1", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index c49eaddae91..e10f0a8ea9c 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-scan", "family": "aiVault.history", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", - "scenarioSha256": "e97c6db772a70ee1912419ac67835b6074aaf9a341d4360389a11465f08092c5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "053ddb72973f": { "activeWorktreePath": "/repo/feature", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index b83d7aa6db8..25542a41c02 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-scan", "family": "aiVault.history", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", - "scenarioSha256": "10011c7c75e74d9c2e880c5458c9156264ae07e91956a80946ede1d4e684952a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "05215ca2ea35": { "name": "aiVault.listSessions#1", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index 05c1f3a3d2b..941a90e1738 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-screen", "family": "aiVault.history-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", - "scenarioSha256": "d52d3c5858298a4a6a90bd9a8986b780004477de105fe93f6303d9c303ffea38", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03106dceb986": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 5ede6eb120b..fc4ec4c344c 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-screen", "family": "aiVault.history-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", - "scenarioSha256": "f50f63c4e2a69793b3d322ed16089c4241ff6169d8f9549106480230fb8dd5e7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03106dceb986": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json index 02164967d24..48e3025c985 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-launch", "family": "aiVault.resume-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "ebf1bbc01ad7704fe79eff0969fcc2d4af8ebfa7c2410d2ed9d3ae8c6976b434", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3e6273f4fe55": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json index 54b025004f9..34d3c162ba5 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-launch", "family": "aiVault.resume-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "281ccf5a082f3788ae8bf19f742ea4baf35c20c78bc91d7d91873845583e1a91", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bce25f5646d": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index 55d31ba3d5d..c692e7e0d75 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-launch", "family": "aiVault.resume-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "941fcf202417c94ef546f5ee331983689d8b72397dac6f61dda944ca24abed9e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bce25f5646d": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 48db573bca7..7b39f3d0e17 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-launch", "family": "aiVault.resume-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "f0ac1c996e5b1b4043e0a081978476c6c2dd8a5d517d5752857721205a588fb0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bce25f5646d": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index 091cbba9667..60057c22721 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-preparation", "family": "aiVault.resume-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "dc9926f95475413627315e1f1c96740ececa697c6a0a5e3c42e18cfd4d58448a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c21b98bedb1": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index 722569f79e4..9079cd752fa 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-preparation", "family": "aiVault.resume-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "6719aea706086a68709894546f9595264407db2df94fa56180cb3c68b08aaa69", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02733b10ba3d": { "failure": { diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index ca728868ddd..ef03d4498d0 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-preparation", "family": "aiVault.resume-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "3afaf2807506f5bde2159b367f34c6b777739d6aad564796d54ac0da05b5bd02", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5d9b438a0a47": { "failure": { diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index cbad473015a..0eb53c63b7f 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-preparation", "family": "aiVault.resume-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "a5eedea5f551e0ca0e143292f1d9aa8920dd7af62a02d8e8777666ab3779c019", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "88c8bb20eb6f": { "name": "aiVault.prepareSessionResume#1", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index fb50a2faf88..f8a073da667 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "23ffc912a432dcd3ff70be1903a8d518cf85634f27a2be6d21585963e338e7e3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "32c9018052ba": { "name": "files.searchPaths#1", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index df8f39f589b..42b19181b12 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "project.update-metadata", "family": "project-explicit-false", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "b31992be2f91bd61fbe1b8a5400da3b7a56753564b0b0b2b38bc5d549812d693", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c4dced3e005": { "error": "", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index 19d44634a48..2d981c34bc8 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.issue-detail", "family": "linear-detail-barrier", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "130e493fcd7765e037405f59e6cc78a0cc1793b1ae092cad933ff9d5a9df8b7a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056f47b204a": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index cabb31e6a66..726f1dab389 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.dialog", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "95f377bc4d8bf1248cafed26b2e9f425ad37e8d3c99e354bd6a8c67eb9bd9b1b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "93f4e9f3ead2": { "name": "browser.dialogAccept#1", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index da8036a9aea..dede085018b 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.dialog", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "5071188ee492a4ced5be793b2dab32baa9750ee6535128635f274fd79198e2f1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "44cc0151aab3": { "name": "browser.dialogDismiss#1", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index 11721f74506..48384c64427 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.keyboard", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "eb2294c30af70ebc2bac092dc5105249ae8cf1941f750406622f7ff6dd70cc1b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "6104536ec606": { "name": "browser.keypress#1", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index bd6c2112a32..f6d5b09f049 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.pointer-click", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "73faa87b359a11959543542016295bb6b36a10934dd99ad5c1a77a4290a608cd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002a5bcbde42": { "name": "browser.mouseClick#1", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index 83476c28672..6730a3d8648 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.pointer-click", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "34efd4d8568ac15d4e67d475e28194522d6413ad27dce1d6850718af15b3f874", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002a5bcbde42": { "name": "browser.mouseClick#1", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index 94ad174a33e..c9ed33e13e9 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.wheel", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "e1bc21248ccff45ff217e385a51618b6f5724c037bfbefa03bb76a48dd4d793b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ea51271b822": { "name": "browser.mouseWheel#1", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index 1f5f6ec7abe..578bc1b2d63 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-terminal-attachment", "family": "clipboard.image-attachment", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "5411e29436e9faf18a1b81f369082f3ae087a9a96a2e1ea0f97e65e9391bfd0c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08a3a6323626": { "name": "before-terminal-send", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json index 577c84ef759..6fd39276a28 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-terminal-attachment", "family": "clipboard.image-attachment", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "ea04d03c15d3cb94be7fe111a8a6219c4e0304095679bbae62af623f5b36c9f4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08a3a6323626": { "name": "before-terminal-send", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index bb828ba1673..14898d57580 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-terminal-attachment", "family": "clipboard.image-attachment", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "e6be7d5dd6b4f5083627a3ba864b1b040d71bf72b60ad940b7d0d575964a7b80", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7ed3d39f0607": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index b98b684f5eb..2e9aeec1063 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-terminal-attachment", "family": "clipboard.image-attachment", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "d6db064741cd68d6ad8a3e490a774d8145adb4a5b7cca8218f5c972b1a31f93b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "080f28c67e30": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json index 2f800d92b7a..caaeb657cf0 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-terminal-attachment", "family": "clipboard.image-attachment", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "e7f492523421873f045726e15ec95eac26d43f7e971a33fd89ec1a9749492987", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3c402c915494": { "name": "clipboard.startImageUpload#1", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json index 1a1d3056c2d..a4cb71399fe 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-upload", "family": "clipboard.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "195c2f15d81c70012ea88750ab3f84bfe512f7e422f7d2d09fb7919bd9cfd85a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "8e6c4f65042a": { "name": "clipboard.appendImageUploadChunk#1", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index 6f0b9baf187..2cd9d763783 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-upload", "family": "clipboard.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "160101326d39705c2036c12646ff6b13d997a1cf91bdc86e5e29279ba31867e4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "261840506035": { "name": "clipboard.appendImageUploadChunk#1", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json index dc1bad47e5c..aa6b0b22c0e 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-upload", "family": "clipboard.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "89ffa843d08ee27dd44b7bba507ce84661b0df73c35f7a8c273e004604710dc9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7bc2e4227914": { "name": "clipboard.startImageUpload#1", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json index 5f05ac6746a..7af74098b99 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-upload", "family": "clipboard.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "bf0227939c2d41d6a1ebc5b07a31196043e78f1580811bd32ec6fc5f447f5934", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "765ab192e1a5": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index f117d764a10..80f01e188a8 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "accounts.codex-reset-credit", "family": "components.codex-reset-credit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", - "scenarioSha256": "00260be9809576607ac91bfacd9fa6cc4f8a190c6b88804c977ea07d36d7162e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a07efb53f1e": { "settled": { diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index ba7be42c230..4f5acd8716a 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "accounts.codex-reset-credit", "family": "components.codex-reset-credit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", - "scenarioSha256": "d85a4031b701e563941afcdd7375cf78a1ee1487a0dab722f8516c2bc8d3dcee", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "074c5080c062": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index 298a0a94eb5..6e22623c984 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.codex-reset-capability", "family": "components.codex-reset-capability", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "88570b9d2376863c7f88d7ed8c745a5fb771deddbc7409f8944fa861dd4bdce9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "578b9d38ecc7": { "supported": true diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index 583273d6181..23e6387b654 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.setup-script", "family": "components.setup-script", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "d4052f119c7ed68dc922c8beeb0074701f1532b10e48e284fb71aa165a17e437", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2bf6d40fe6a5": { "name": "repo.hooks#1", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index cb200b89d4d..52042bc33d8 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.execution-target-local", "family": "components.execution-target-local", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "2e0d3021621698b63117e250dd5e9762b5bfb3dc1911e27c510e9539bd2ee6c9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e4520fe6576": { "detected": { diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index b76ff775677..928d4187789 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.execution-target", "family": "components.execution-target", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "82b891c7a7a2f255e2d22a372ee6112c9cc1f650259244e87f2e8c1356e97e5f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0355054e5e71": { "name": "ssh.connect#1", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index 82b54bda98e..1b35ff75735 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "b7579013e65f0f5fe503c10cf2294cb9d4ac1938108275de001db6e02cf2cc21", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "284ce622e74a": { "name": "worktree.show#2", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json index fe7dd7a58b5..7271a34bd62 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "6c71b0f217a464dffbc6f5736605b840edac74ebaf0664edc0ab85984bb64328", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "365c17523d76": { "status": "fulfilled", @@ -40,12 +31,12 @@ }, "snapshot": "unloaded" }, - "551c785629a1": { + "ce804facf021": { "name": "git.branchDiff#1", "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"base-oid\",\"headOid\":\"head-oid\",\"mergeBase\":\"merge-base\"}}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\",\"headOid\":\"f0e1d2c3b4a5968778695a4b3c2d1e0f98765432\",\"mergeBase\":\"0123456789abcdef0123456789abcdef01234567\"}}}" }, - "714ef7b515d2": { + "d24ea9d9187c": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -57,10 +48,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -93,8 +84,8 @@ { "id": "branch", "observation": { - "sender": ["714ef7b515d2"], - "payloads": ["551c785629a1"], + "sender": ["d24ea9d9187c"], + "payloads": ["ce804facf021"], "settlements": { "branch": "365c17523d76" }, @@ -105,8 +96,8 @@ { "id": "no-compare", "observation": { - "sender": ["714ef7b515d2"], - "payloads": ["551c785629a1"], + "sender": ["d24ea9d9187c"], + "payloads": ["ce804facf021"], "settlements": { "branch": "365c17523d76", "no-compare": "38fb361f406c" diff --git a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json index 5bf8483bd59..7195520bfec 100644 --- a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json @@ -1,23 +1,65 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "3e7fa054f77587b9ac24b6732a9926273b0f2ff35a266e633d0ccd1dada932cc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1069be6ec9cc": { "name": "git.branchCompare#1", "ordinal": 10, "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchCompare\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"baseRef\":\"origin/main\"}}" }, + "15f193a5a1c7": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "168d1f64ca9b": { "name": "git.status#1", "ordinal": 2, @@ -75,57 +117,6 @@ "startedAt": 0 } }, - "4a2489a297d0": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-5", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -344,7 +335,7 @@ "d7e7bd22e9f6", "8f95d359947c", "b7fd51d232e7", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", diff --git a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json index da678dc98e2..becc31b4f3a 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "d1b04fe2945a2799ac8465d8fd9e45ab790ae29b401a0cf4fef68ebc5fa3cc76", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0698901154de": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index ad6442801d5..fa0877294a2 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "fa0a81462196458fdded5b7c00aa4e73975c2111afdd8dac115871490a481da2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1069be6ec9cc": { "name": "git.branchCompare#1", @@ -44,51 +35,7 @@ "startedAt": 0 } }, - "168d1f64ca9b": { - "name": "git.status#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.status\",\"params\":{\"worktree\":\"id:repo-9::/w\"}}" - }, - "1cb6dd8e325b": { - "name": "worktree.show#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "worktree.show" - }, - { - "name": "params", - "value": { - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "worktree": { - "diffComments": [], - "mobileDiffReview": { - "files": [] - } - } - } - } - } - }, - "4a2489a297d0": { + "15f193a5a1c7": { "name": "git.branchCompare#1", "ordinal": 9, "args": [ @@ -127,18 +74,319 @@ } ], "summary": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", "changedFiles": 1, "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "status": "ready" } } } } }, + "16364e8e8caf": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, + "168d1f64ca9b": { + "name": "git.status#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.status\",\"params\":{\"worktree\":\"id:repo-9::/w\"}}" + }, + "16c1635cc4a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, + "1cb6dd8e325b": { + "name": "worktree.show#2", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "worktree.show" + }, + { + "name": "params", + "value": { + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "worktree": { + "diffComments": [], + "mobileDiffReview": { + "files": [] + } + } + } + } + } + }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -283,267 +531,10 @@ } } }, - "e13943e37fc3": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "e39817462870": { "branchCompare": "unloaded", "diff": "unloaded", "snapshot": "unloaded" - }, - "f880a1519497": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } } }, "recording": { @@ -569,7 +560,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -579,9 +570,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index 7eca0de25a8..e59f3884e07 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "182f37fbe6ae7c0694b50d603ecd4a03bc9a8c7c9738ebb775b47eb5f3b9660f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "14804a5e414f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json index 5202ad86447..c712ae8a777 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "538b68485a2268d311fcc7e13ff1a3e446ba4aa010bc18633af8e938a6688257", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "168eeea61142": { "name": "git.diff#3", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index 78516f75ee7..44d9c50bb19 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "a927e85c3ae58cd3b017955fe28aeffec6a5471b51d782f116639a3aaf4af77d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0358eb8770a0": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json index a50d9ea8e1a..8f336768406 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "297ea4075333e178ca20247eb0abf6600efb44fe2b33f5142d1c1cabffb5a2d3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0358eb8770a0": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json index 564f53ff194..42f0fbceaf8 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "f1844c2608ce90250fddfc78c0b35bb1bf3647598a5ab2914eca0d8cb4403a38", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "220e44211af6": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index 012d31f7fc7..637e736c23e 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "0f76b96408081737b3d630ee837dbb80bcce6670b6acc4f1f7c033a69bd40533", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3445711170ff": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index 8213f3e4961..6dfff417c56 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "1ada35966dfb65afbb9b3dc8139961b8f042e2d53241b9608a080d0e655a5987", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "279749a84cb2": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index b8c88b89a28..bacb74b39f0 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.explorer-screen", "family": "files.explorer-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", - "scenarioSha256": "ec02d3f76085619fad35231e12654ec6e926e423c6799fd0df53708743000612", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "33bb136847f8": { "name": "files.readDir#1", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index fb6bd93a03d..bf3cce76963 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.explorer-screen", "family": "files.explorer-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", - "scenarioSha256": "0b87a230cf1c4219e415c840a088502c8bda7cd2fb525bde1a83a5903d6fd96b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1fac8f3b8f44": { "crash": { diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index 6da2e1bc832..3eea99312b2 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.mutation-ownership", "family": "files.mutation-ownership", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "8d24f52eb4194c3bc5d9f0dcabade6d7a09c066f79f911657647ed21dbecb3b1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4e53935c13ac": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index 94bd352a764..855dae1641a 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.mutation-ownership", "family": "files.mutation-ownership", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "4cd0be3a1c338b4b68211c717fd10738653c553fdb3b072db6706ff8175a8bd1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "088efd8ff3f7": { "name": "ssh.getState#1", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index ab8ac02d2a6..75cf78728ca 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "dd8b7a0916a84c7d763a163759c02210ae99f8fbccfccaa98796b8610c5da97c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "500d95d47092": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json index 4a232dcf21e..a909fc1ce45 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-artifact-image", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "39fe4bce80a2323dbc276235daab48e7fe55d1fdb9c1ea1eeac7c17bceb8df18", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7659b8b575da": { "preview": { diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index 50f04a169e7..466d1a54ba9 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "d15c1f4e0f95b5c49a8f889d2d37458225293d7306c8439690d972d2df4c29c0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7659b8b575da": { "preview": { diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index 501314f8808..98e3b9a44f7 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "055f3b45442c1736f10ee98c493e2ece1885fdb68d925ee627e2ba20853537e0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ea42bf424ad": { "name": "files.readTerminalArtifact#1", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json index 797e0f5c7bd..a65b9babfb7 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-worktree-image", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "767af05bb58756f4adc960b95ee6547cc28145c7f6c7c6a4b27582aed9741e37", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "70f782fa6ef2": { "name": "files.readPreview#1", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index 3202b46f6d5..66bc880db9d 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "0ffaffb472b663e08a42317d799a2a000211dda5b127fb21cc64f04f73800130", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "70f782fa6ef2": { "name": "files.readPreview#1", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json index b67eb6d59cc..ce3b4efe91f 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-worktree-text", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "21c3c61a18939873157f74c8d2d28bceae01abc702565af1ca897ab7304cb93c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1cf4496bc8c0": { "name": "files.read#1", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index 933942cfdb3..e6472887b63 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "bcba4a7d9d929078c5ed80fc7e1acd45859d0b4c559767a919b0396dc6a70a3e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1cf4496bc8c0": { "name": "files.read#1", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index 7dc10c03d1b..cfb69cb7ad4 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-save", "family": "files.preview-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "8b5c3e87d989966d7b252f19a537040cd5078ba9355a824e7e49af3424390a3e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "300dad960cb6": { "name": "files.writeTerminalArtifact#1", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index 12bb53d22b7..005f2fd5ce3 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-save", "family": "files.preview-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "6e124d16173074d851d58593b20b25889ed13a3d8021c08fbed53b85a7d3196e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "24ffb7059792": { "name": "files.readTerminalArtifact#1", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index 0658bd72712..de9da6f85b4 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.tab-doc", "family": "files.tab-doc", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "b636221f719dae19a3af6772b687b0bcb300c9910645d23e98c309578ec1c5c5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "096b0c48dd10": { "name": "git.diff#1", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index e702147addf..a863ddf29ed 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "home.host-accounts", "family": "home.host-accounts", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", - "scenarioSha256": "366426641e25542fcc6fcd351ece6a1ee897c8b3974c08eb7557eadfa4d3b06f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "16b4c187b90c": { "name": "accounts.list#1", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index 8cf4e0187da..738fe330967 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "home.host-stats", "family": "home.host-stats", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", - "scenarioSha256": "bd5f4e5f24a29d96c4c98950691c6571918332f71ebb1f29ee97a9abc857ac29", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "44136fa355b3": {}, "94616379becc": { diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index adadbb81803..88f752d0cbb 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.view-settings", "family": "host.view-settings", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", - "scenarioSha256": "1ee6031227fa3efd5b36841afa60f7b2264eacdd9c6126e5851d5acb39ffaadd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0078bce46c03": { "name": "ui.set#1", diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index f1de161f46a..ad4cde4e3cb 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.worktree-actions", "family": "host.worktree-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", - "scenarioSha256": "720add498c79425ca8efc9764fd5d8307fe33bc891842604cfc899f770b79811", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "047f5e4dfc2b": { "name": "optimisticActiveWorktreeIdentity", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index ca8bb14a5df..6fff009906f 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.worktree-actions", "family": "host.worktree-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", - "scenarioSha256": "5dd5e7eabaabba1e471b13958f59891c3b28f553087c96315598c83a14ded7e7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "145530c614fb": { "name": "worktree.rm#1", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index b8ac4ff58b0..f524e615f6a 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.host-refresh", "family": "host-worktree-refresh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "6ac11f1ab42fea3b718d9714e512a4e8a344aea66ae170fbe9ef2b5ae82dbe0a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "121f5eaa52a4": { "name": "fetchWorktrees", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index dabe2fc5872..d2c3253735a 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "d6c57a5153d915f0a0c0fd9e305cac70b41b7eb8be226fc865227ebf1821e5d1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13ef648b37c5": { "name": "files.list#1", diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index 100f21460dd..a81d89d082d 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "2d5c6dea28aa1a7bb9e4aa14a4c8441527d9ad401ad30161f05ea1f8da836bb2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1a10a74b4157": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index 63331d4bc2c..9a6a5d73078 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "3471f5bcd6923c7b8ba3a737bb45b5239689deb78c00e85a828f38a6d6d68a05", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2f1953cc0315": { "name": "files.list#1", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index 133dd37378e..cc6a55702f0 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "73a468d5c7a51c2dbb7af2642f0050d05d861fce29295460c48d7c51f86bf57f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "003a57e2bf31": { "files": ["alpha.ts"] diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index 7e2a3ff2dde..a4e00972d1f 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.issue-detail", "family": "linear-detail-barrier", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "8be12d116865d27e8dfd37921d2c723d63da101ec1197b1f5b2d9510838e1943", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056f47b204a": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 28cd115186d..8784dc31168 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "46bbafcc57fe2e3aee41a14bc26a0375b7b56e58030705fe4c28841a272b2560", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2f1953cc0315": { "name": "files.list#1", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index 7ee44f37fc4..9e047e28269 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "eb80283956c93849778f23cbabf1dbf83b72744197af4f6f50335b2fc1590d87", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02740e15d2b5": { "name": "settings.get#2", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index 9c5f08c680e..81f560ce57d 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "3a8eab831602443d320ca0aa0f35dc269d8d511e76bdae8fd025c433561d068d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index c72a6b5d824..a7068725a56 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "136fb1d8d5925ad12ba22f4dd6c72573a9ad03b6a6ec8308668f0d9cd71aa36d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1b3e4be6609f": { "name": "settings.get#2", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index cfe5c67870f..ad7897dd382 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.select-workspace-picker", "family": "linear.select-workspace-picker", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", - "scenarioSha256": "b6eb40d9a91c89179483afec93fbc121b99ef679d3b2433575b26694d0c577d5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2a8d45397a3b": { "name": "linear.selectWorkspace#1", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index db71c42b710..ddf0af6340b 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "056abf42b34537025fed30a4401bd465c93bc21558babb826f21b5c803b487eb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c439cbde351": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json index 8d205778875..6bcd41482d1 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "81ed7c1a4276cf1d891334c64c06362b88b5f9690b3ebae6d0988559b1faab0f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "001cba249e32": { "name": "agentSession.create#2", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json index 791ede81d7d..77e30e9cdbd 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "eed54b1c4c77eec4b411d8daa197b20c8cfb4b86ac2d01f1d7ee2de23c27dff8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f63acf891a": { "launched": { diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json index fe99a2b2814..baeba916d86 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "211e3780edc0ff5fa0529c0de0e8bc2fd746a19c8a6b4e49900f6c4e56d53f7c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f63acf891a": { "launched": { diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index 68b76baf798..e4911d18bbd 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-scan", "family": "aiVault.history", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", - "scenarioSha256": "efb8d1cd2a2ff0ade4cdc48a1aacec565e33b405bbe95c100b86534cdde49740", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03941f1abe9e": { "name": "aiVault.listSessions#1", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index 3d60a85ab04..9655b966cda 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-screen", "family": "aiVault.history-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", - "scenarioSha256": "30e00c0d94413aab61b164fb9a658e526448addc4c42fd8892b1c28335d30beb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03106dceb986": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index 2fc02ff1861..e6d02c968f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-screen", "family": "aiVault.history-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", - "scenarioSha256": "b146be741484f2a6dca25e97f52b9ed10f8a2b28bd00e6b56acffbcd82136b2f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01800f06bc8f": { "name": "status.get#2", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index 4f9bbfc16b7..51eb10049c2 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-screen", "family": "aiVault.history-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", - "scenarioSha256": "5365449c24d789c6c01604b520b795502d0bec352032686efecd121fc4497f96", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03106dceb986": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index 5530b93969e..3a3617c092b 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.history-scan", "family": "aiVault.history", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", - "scenarioSha256": "a5698ca720dad08561a509c9c18f7586611fc68c58bd86791f4fcefc7915ab1f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0474a1603b70": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json index 175556b1b0c..1a860458639 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-launch", "family": "aiVault.resume-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "f721ab4438c4183927ce5ebc5fd6f1f18414701a5fa3b2807c359785829962a3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bce25f5646d": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json index 241e0f99409..78e30d630db 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-launch", "family": "aiVault.resume-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "aa812132ede83e4aced73a644e996df82a38bfc70ead70a5db2e9af8a8b1bfff", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bce25f5646d": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json index 74f84d2526f..ea60f888adf 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "aiVault.resume-preparation", "family": "aiVault.resume-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", - "scenarioSha256": "c3b400967b0b1c7bd3f82a278f4b10855ade72d384922ec4d34795c7bb20084d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02733b10ba3d": { "failure": { diff --git a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json index 6da9cceba8b..48ddb32b2e6 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.dialog", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "9cae732e4227d4c2fe874a5fb2e104552c16cbc91b59523de8fd46454660cde8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0473e0c1da49": { "name": "browser.dialogAccept#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json index d943a202d3f..f5e1a8ba899 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.keyboard", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "fc3411f3f4cb58b6a1338ea943f59446fda7819931814e9ab002e35e2de42462", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0471cabae9b6": { "name": "browser.keyboardInsertText#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json index 1311ac78659..8de7e96462b 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.keyboard", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "c1df871e84d4399e9a447caaff244241933c0dfc7c3d5e114d022e54b0ed7ed4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0b58fd6acaf6": { "name": "browser.keypress#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json index 2b425803af0..231de897b6f 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.pointer-click", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "91a1ef5e07178585f583dd1609543c3bfb0c71d13eb4ce1eedab3acc2635f0da", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002a5bcbde42": { "name": "browser.mouseClick#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json index f4219c2aeb6..925c25a52f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.pointer-click", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "a302565f0172a763bfb0865389849703d3ceb52d848e1bc40f2204ce7f97bbef", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002a5bcbde42": { "name": "browser.mouseClick#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json index 1e2c03760f2..72a4eb99efd 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.pointer-click", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "33cb8faa9c8b50945a2d8275140c65f483b8023c890a5a16165a759f0a59baad", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002a5bcbde42": { "name": "browser.mouseClick#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json index 43bed4caabf..64a2af60f2a 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.pointer-click", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "96c4862084d7ba2a8e07047abcee1716220ba35d9c754bcb97f7227020976c95", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002a5bcbde42": { "name": "browser.mouseClick#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json index ca312d82abb..6b53b77be66 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.wheel", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "c1283bbfb340e968bbd4c86ac63489995d6191290751316228d27779dc6a2c55", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "047676470457": { "name": "browser.mouseMove#1", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json index 8ee48dd1fd3..60b5365966d 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "browser.page-commands", "family": "browser.wheel", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", - "scenarioSha256": "092777c9ce457dcae95eafbe083c70510580569ac74e31c0d4224ac94b9fad76", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02bf414951cb": { "name": "browser.mouseWheel#1", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json index 99d3eb12699..2b801f6b4ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-terminal-attachment", "family": "clipboard.image-attachment", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "fc690a2ac59a6fbcdc08f9b91e769cd793f5a48d9034a50c2d587ce0d0fca3d9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09c6a4baa397": { "attached": "unattached", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json index ddda539bc4c..0bcd8d14a78 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-upload", "family": "clipboard.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "fa69748b6d0e29abc37757b48bcb22dac07af1686554200ceaf18b4e3d62e4ac", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "046968b3bb5e": { "name": "clipboard.saveImageAsTempFile#1", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json index f464ff8ef8e..820f3980651 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "clipboard.image-upload", "family": "clipboard.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "a4bea606723da4d4a86db6e04c46266801149a852a8861b15e637d5c0855c679", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1a21246f762d": { "name": "clipboard.startImageUpload#1", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json index 9879f9282a3..9a97fb85e46 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.codex-reset-capability", "family": "components.codex-reset-capability", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "06c2ad6d4b464f889a640be7a238f6d0ff7c54b0e93fb5ea22aaa856dadb0336", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0474a1603b70": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json index ea1cb61598f..8f6dbdce1c6 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "accounts.codex-reset-credit", "family": "components.codex-reset-credit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", - "scenarioSha256": "cc6de73be00be072f9a3b7fd63459fd1a529c45d0916a67d5fe6d90dbee61e91", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a07efb53f1e": { "settled": { diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index cb7bae2e7aa..e4254f30cc9 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.execution-target-local", "family": "components.execution-target-local", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "f88ee2f5d19b19cc53dca5180a9b5936a13a00a82e8a4636a5e895262b669dec", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e4520fe6576": { "detected": { diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index 5b35c3911fb..5dd0685f8e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.execution-target", "family": "components.execution-target", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "79a49cddf66935007afb9be8a30593b778f02237894e5fd4d2898b526fc125df", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0355054e5e71": { "name": "ssh.connect#1", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index 288f5e269b5..945caa098c6 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.execution-target", "family": "components.execution-target", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "8b8f7fe7227d44330e216e0bf5d366c41b54d9bf76acfb24df2c1770984b9f27", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0355054e5e71": { "name": "ssh.connect#1", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index 86add409131..6e02a2a9e75 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.execution-target", "family": "components.execution-target", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "6832d23c6500e4fcb20abe7c53bc4f5abe72180dc0ad747a4907b82d99bc75d0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0355054e5e71": { "name": "ssh.connect#1", diff --git a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json index d1d9e1bd99e..310446b4c9a 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.repositories", "family": "components.new-workspace-repositories", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", - "scenarioSha256": "95f7dbb11bca7203d29bd13230d4a286f49ff20596535163094e1bff73e7f3cb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12e70439f294": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json index 965623d6156..5b41d56509a 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "components.setup-script", "family": "components.setup-script", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", - "scenarioSha256": "a844134d7bad3c7c12107d60dbd298f5cfba778703fb9dd0f7ad454615d26b07", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2bf6d40fe6a5": { "name": "repo.hooks#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json index cc88c5ad7b9..7eebec47af6 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.explorer-screen", "family": "files.explorer-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", - "scenarioSha256": "38b5590173c1f6791d1b35c0f036e2f844ed4b0e39c880e8e376c5f314adaade", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13db9ec07bae": { "crash": { diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json index 8e1d86de1b9..ba7472a80d7 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.explorer-screen", "family": "files.explorer-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", - "scenarioSha256": "8c1fa604104c5551b8418af225c9420b1292f0c55b392f847985947c66749959", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09c69b142397": { "name": "files.readDir#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json index 2b192f3aa84..e0fb53e4d16 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.mutation-ownership", "family": "files.mutation-ownership", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "5815450e8d07f463423ca0bd8237830791c220234201abffc6fa13e698913516", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0622950ee1c9": { "name": "ssh.getState#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json index 7c1c3ac751c..701f40da225 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.mutation-ownership", "family": "files.mutation-ownership", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "b9cfb187224a4b42efe8ccfcd96145833730d135c4fffa345716f95991a4700f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "049e3dd3b1db": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json index 5cb190a31ef..d56a093a4ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.mutation-ownership", "family": "files.mutation-ownership", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "0bf3b17048bceb0bc8592405facd99cb8086274509206d9e55cd589a05d7415f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0233661d0a29": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json index a3a8fe88951..e2762255416 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-artifact-image", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "83cad9595793b30d38115ba7118ae1400ecb2af21327853183e91289f4f33a04", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "011a73e13887": { "name": "files.readTerminalArtifactPreview#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json index 159404e1b4c..29365377166 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "dbd20e271999641affbd4b52635c8864e25f08aa6db820a46d0773faa09770c6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0181c916a00b": { "name": "files.readTerminalArtifact#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json index e7b369f7a8f..63c1011884c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "c4a09003352ba4e97a17ec4109cc125298cf7123b317265cc5eca06e8dcc0615", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ea42bf424ad": { "name": "files.readTerminalArtifact#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json index 377bcb8ef86..c3f80406d9f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "ecdeff2713e454925158dde09782713d76f39455729bb25688a6ffcfff154f30", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "022b6743e9ed": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json index 32817b0fb41..bd4b3650896 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-save", "family": "files.preview-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "1a4f7dff351be244712bedb8f495c83a531cfbfc5b5923267d1ed46bf2d6d11b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0181c916a00b": { "name": "files.readTerminalArtifact#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json index 78d0ee50808..865e999fb98 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-save", "family": "files.preview-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "8d443bda91fe5a1fa74910d525bb2ec40f639109bc71afbd42bccd949b3d463f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "15467bba2d60": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json index 8173a17b650..8b71474bf02 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-worktree-image", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "0962cb776492001a60db2ca89d715faec94c648962f140077fc9a85355784d81", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0639efe16769": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json index 34e77850885..af5b6a91640 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.preview-load", "family": "files.preview-worktree-text", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "08d73805619f00748caa5726a7fb0d9b9c561f93ec1e6dd28b27065a4515d65d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "072647254b82": { "name": "files.read#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json index 8312731b553..8e9eb6c8448 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.tab-doc", "family": "files.tab-doc", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "65895c028637185238baf4fd4f1297c11f528a289fe8173a41c48dc5a0b37c26", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "072647254b82": { "name": "files.read#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json index 738bb631480..1f2478046ae 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.tab-doc", "family": "files.tab-doc", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "2ceca0ecd901fd78838fcbdc789cbb6b96f04674b850a0994c7611f5db804915", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "096b0c48dd10": { "name": "git.diff#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json index a4c5d13082c..8db4996e9cf 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.tab-doc", "family": "files.tab-doc", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", - "scenarioSha256": "c4db2424b8a35fd97b3fea00b4dd91a3c8d50c6fb73795811ef5402e3d14f8df", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "05ed9af7a4f4": { "name": "git.diff#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json index e340b90e735..e8a412fb8cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "73dbbb8b96662af57240194be4af5726697d402b624a807d003ab56fea04dbd7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0358eb8770a0": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json index e28a23102bb..96bb433bf93 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "files.terminal-path-tap", "family": "files.terminal-path-tap", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", - "scenarioSha256": "ba8728e890e0e9c10ea163bd16f4f99fbc9727cd2f9c4ed69c56436d8bc29f89", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0358eb8770a0": { "name": "files.resolveTerminalPath#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index ec13c65fb4a..22e1109d1cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "5605a2984d7692aa80e5e38f804bdfed4b1ce8ac2102def5dc728b1a79dc1acf", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "006a91b993e0": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index 25f00c15ba2..bd4ceadedaa 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "ed0e97ead1aad0b45bdfc48f5fe4e498810d0cfee88f07d3c6228db56fda1dd9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "006a91b993e0": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index cbdea59fbc9..e5d469e6f14 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "1db6919b94df3b8548838ff4c206fafa3a09ea096b17c04483f78f9321ccb1ba", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "006a91b993e0": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json index 8aec0ab46c9..5329097c4d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-diff-preview", "family": "git.branch-diff-preview", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "42879d7300691a80aa0d09a2aeb5f8cb9438fc72b0ac7f24b0bbae4309f3a2ca", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "009bc53fab97": { "preview": { @@ -20,7 +11,7 @@ "truncated": false } }, - "040bae459f65": { + "0e8e97b1d8a1": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -32,85 +23,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "0c58735c9f94": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } - }, - "1908cad2fe5e": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -129,22 +45,11 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true, - "result": { - "kind": "text", - "modifiedContent": "const a = 2\nexport { a }\n", - "originalContent": "const a = 1\nexport { a }\n" - } + "ok": true } } }, - "1c859cdf2a2a": { - "preview": { - "kind": "error", - "message": "Unable to load committed diff" - } - }, - "2f49182e433c": { + "17fc56a8dcf7": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -156,10 +61,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -186,7 +91,13 @@ } } }, - "4085fe610744": { + "1c859cdf2a2a": { + "preview": { + "kind": "error", + "message": "Unable to load committed diff" + } + }, + "2ecb70ffd07d": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -198,10 +109,198 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "30b71879f270": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "5079c917cb98": { + "preview": { + "kind": "error", + "message": "Unknown method" + } + }, + "52eb9a25445e": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "77383ea2ed64": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "7ae5d3365eac": { + "preview": { + "kind": "error", + "message": "transport failure" + } + }, + "7ef737811204": { + "name": "git.branchDiff#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo42::/p\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\",\"headOid\":\"f0e1d2c3b4a5968778695a4b3c2d1e0f98765432\",\"mergeBase\":\"0123456789abcdef0123456789abcdef01234567\"}}}" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "9ea13627f23f": { + "preview": { + "kind": "error", + "message": "The host sent a reply this app could not read (git.branchDiff)" + } + }, + "b31e81235516": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -230,12 +329,7 @@ } } }, - "424c6bc6b69b": { - "name": "git.branchDiff#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo42::/p\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"base-oid\",\"headOid\":\"head-oid\",\"mergeBase\":\"merge-base\"}}}" - }, - "4c88fa3f2c0f": { + "b6688a6b6322": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -247,10 +341,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -276,13 +370,7 @@ } } }, - "5079c917cb98": { - "preview": { - "kind": "error", - "message": "Unknown method" - } - }, - "632df3743f00": { + "b699fd109439": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -294,10 +382,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -311,17 +399,23 @@ } ], "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } + "status": "pending", + "startedAt": 0 } }, - "6cd0d78e439c": { + "c05731357bc1": { + "preview": { + "kind": "error", + "message": "outer refused" + } + }, + "c85be3836e1d": { + "preview": { + "kind": "error", + "message": "" + } + }, + "cebc5d8f666b": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -333,10 +427,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -363,17 +457,7 @@ } } }, - "7ae5d3365eac": { - "preview": { - "kind": "error", - "message": "transport failure" - } - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "9d3cae3e85f1": { + "dadf774f4471": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -385,54 +469,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, - "9ea13627f23f": { - "preview": { - "kind": "error", - "message": "The host sent a reply this app could not read (git.branchDiff)" - } - }, - "a46ef8512803": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -453,24 +493,27 @@ "id": "frame-1", "ok": true, "result": { - "error": "refused" + "kind": "text", + "modifiedContent": "const a = 2\nexport { a }\n", + "originalContent": "const a = 1\nexport { a }\n" } } } }, - "c05731357bc1": { + "e5400edc0fdd": { "preview": { - "kind": "error", - "message": "outer refused" + "kind": "loading" } }, - "c85be3836e1d": { - "preview": { - "kind": "error", - "message": "" + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" } }, - "de15cf30cb71": { + "fb1f0e731b51": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -482,49 +525,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "e01cb729f0b7": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -550,19 +554,6 @@ "ok": false } } - }, - "e5400edc0fdd": { - "preview": { - "kind": "loading" - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } } }, "recording": { @@ -571,8 +562,8 @@ { "id": "sc-branch-diff-previewed.prelude:diff-pending", "observation": { - "sender": ["040bae459f65"], - "payloads": ["424c6bc6b69b"], + "sender": ["b699fd109439"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "9270aeb7d9c6" @@ -584,8 +575,8 @@ { "id": "sc-branch-diff-previewed.normal:settled", "observation": { - "sender": ["1908cad2fe5e"], - "payloads": ["424c6bc6b69b"], + "sender": ["dadf774f4471"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -597,8 +588,8 @@ { "id": "sc-branch-diff-previewed.result-absent:settled", "observation": { - "sender": ["9d3cae3e85f1"], - "payloads": ["424c6bc6b69b"], + "sender": ["0e8e97b1d8a1"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -610,8 +601,8 @@ { "id": "sc-branch-diff-previewed.result-null:settled", "observation": { - "sender": ["4c88fa3f2c0f"], - "payloads": ["424c6bc6b69b"], + "sender": ["b6688a6b6322"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -623,8 +614,8 @@ { "id": "sc-branch-diff-previewed.inner-ok-missing:settled", "observation": { - "sender": ["a46ef8512803"], - "payloads": ["424c6bc6b69b"], + "sender": ["77383ea2ed64"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -636,8 +627,8 @@ { "id": "sc-branch-diff-previewed.inner-false-string-error:settled", "observation": { - "sender": ["2f49182e433c"], - "payloads": ["424c6bc6b69b"], + "sender": ["17fc56a8dcf7"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -649,8 +640,8 @@ { "id": "sc-branch-diff-previewed.inner-false-object-error:settled", "observation": { - "sender": ["4085fe610744"], - "payloads": ["424c6bc6b69b"], + "sender": ["b31e81235516"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -662,8 +653,8 @@ { "id": "sc-branch-diff-previewed.outer-refused:settled", "observation": { - "sender": ["0c58735c9f94"], - "payloads": ["424c6bc6b69b"], + "sender": ["52eb9a25445e"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -675,8 +666,8 @@ { "id": "sc-branch-diff-previewed.outer-refused-no-message:settled", "observation": { - "sender": ["6cd0d78e439c"], - "payloads": ["424c6bc6b69b"], + "sender": ["cebc5d8f666b"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -688,8 +679,8 @@ { "id": "sc-branch-diff-previewed.method-not-found:settled", "observation": { - "sender": ["e01cb729f0b7"], - "payloads": ["424c6bc6b69b"], + "sender": ["fb1f0e731b51"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -701,8 +692,8 @@ { "id": "sc-branch-diff-previewed.transport-rejection:settled", "observation": { - "sender": ["632df3743f00"], - "payloads": ["424c6bc6b69b"], + "sender": ["30b71879f270"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" @@ -714,8 +705,8 @@ { "id": "sc-branch-diff-previewed.transport-rejection-no-message:settled", "observation": { - "sender": ["de15cf30cb71"], - "payloads": ["424c6bc6b69b"], + "sender": ["2ecb70ffd07d"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json index bcf54ece9dd..19a2b549858 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json @@ -1,74 +1,66 @@ { + "goldenFormatVersion": 6, "operation": "source-control.changes-load", "family": "git.changes-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "afdfde4c58b6ff2047ea1b2f18b955074c0ae23850d4ac8783e69c0724096eab", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "02adc1911747": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/dev", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "02d5719960a0": { "name": "source-control.status-load-success", "ordinal": 6, "value": {} }, + "1d461278a0ae": { + "branchCompareState": { + "kind": "ready", + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + }, + "screenState": { + "kind": "ready", + "status": { + "branch": "feature", + "entries": [ + { + "added": 4, + "area": "unstaged", + "path": "src/app.ts", + "removed": 1, + "status": "modified" + }, + { + "area": "staged", + "oldPath": "src/older.ts", + "path": "src/old.ts", + "status": "renamed" + } + ], + "head": "head-oid", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "hasUpstream": true, + "upstreamName": "origin/feature" + } + } + } + }, "213a7bbd0d32": { "name": "git.branchCompare#1", "ordinal": 9, @@ -197,58 +189,6 @@ } } }, - "35fa5a183b27": { - "branchCompareState": { - "kind": "ready", - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - }, - "screenState": { - "kind": "ready", - "status": { - "branch": "feature", - "entries": [ - { - "added": 4, - "area": "unstaged", - "path": "src/app.ts", - "removed": 1, - "status": "modified" - }, - { - "area": "staged", - "oldPath": "src/older.ts", - "path": "src/old.ts", - "status": "renamed" - } - ], - "head": "head-oid", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "hasUpstream": true, - "upstreamName": "origin/feature" - } - } - } - }, "4533fb49ac71": { "branchCompareState": { "kind": "error", @@ -350,6 +290,57 @@ } } }, + "7ab1473c6d87": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/dev", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "7f14dc4f506b": { "name": "git.branchCompare#1", "ordinal": 9, @@ -987,12 +978,12 @@ { "id": "sc-changes-loaded.normal:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json index 5afd179d85d..edfd55dc63b 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json @@ -1,69 +1,9 @@ { + "goldenFormatVersion": 6, "operation": "source-control.changes-load", "family": "git.changes-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "a428557212fd35e84506b671c2b254d7ce5cab486dada61fc9b5e5357f76df2c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "02adc1911747": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/dev", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "02d5719960a0": { "name": "source-control.status-load-success", "ordinal": 6, @@ -147,6 +87,58 @@ "message": "The host sent a reply this app could not read (git.status)" } }, + "1d461278a0ae": { + "branchCompareState": { + "kind": "ready", + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + }, + "screenState": { + "kind": "ready", + "status": { + "branch": "feature", + "entries": [ + { + "added": 4, + "area": "unstaged", + "path": "src/app.ts", + "removed": 1, + "status": "modified" + }, + { + "area": "staged", + "oldPath": "src/older.ts", + "path": "src/old.ts", + "status": "renamed" + } + ], + "head": "head-oid", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "hasUpstream": true, + "upstreamName": "origin/feature" + } + } + } + }, "21ab22282843": { "branchCompareState": { "kind": "idle" @@ -227,58 +219,6 @@ "startedAt": 0 } }, - "35fa5a183b27": { - "branchCompareState": { - "kind": "ready", - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - }, - "screenState": { - "kind": "ready", - "status": { - "branch": "feature", - "entries": [ - { - "added": 4, - "area": "unstaged", - "path": "src/app.ts", - "removed": 1, - "status": "modified" - }, - { - "area": "staged", - "oldPath": "src/older.ts", - "path": "src/old.ts", - "status": "renamed" - } - ], - "head": "head-oid", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "hasUpstream": true, - "upstreamName": "origin/feature" - } - } - } - }, "3cad02478a4f": { "branchCompareState": { "kind": "idle" @@ -409,6 +349,57 @@ "message": "transport failure" } }, + "7ab1473c6d87": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/dev", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "7e0872c888d0": { "name": "git.status#1", "ordinal": 1, @@ -828,12 +819,12 @@ { "id": "sc-changes-loaded.normal:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json index b84b036551c..f301c00ed1e 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json @@ -1,69 +1,9 @@ { + "goldenFormatVersion": 6, "operation": "source-control.changes-load", "family": "git.changes-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "901b8f44a090b5871b3edc20be72c4f407889b4830fcb00be0e99c8c96274cc5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "02adc1911747": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/dev", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "02d5719960a0": { "name": "source-control.status-load-success", "ordinal": 6, @@ -135,6 +75,58 @@ } } }, + "1d461278a0ae": { + "branchCompareState": { + "kind": "ready", + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + }, + "screenState": { + "kind": "ready", + "status": { + "branch": "feature", + "entries": [ + { + "added": 4, + "area": "unstaged", + "path": "src/app.ts", + "removed": 1, + "status": "modified" + }, + { + "area": "staged", + "oldPath": "src/older.ts", + "path": "src/old.ts", + "status": "renamed" + } + ], + "head": "head-oid", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "hasUpstream": true, + "upstreamName": "origin/feature" + } + } + } + }, "1f09dd1eb6a5": { "name": "repo.list#1", "ordinal": 4, @@ -368,58 +360,6 @@ } } }, - "35fa5a183b27": { - "branchCompareState": { - "kind": "ready", - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - }, - "screenState": { - "kind": "ready", - "status": { - "branch": "feature", - "entries": [ - { - "added": 4, - "area": "unstaged", - "path": "src/app.ts", - "removed": 1, - "status": "modified" - }, - { - "area": "staged", - "oldPath": "src/older.ts", - "path": "src/old.ts", - "status": "renamed" - } - ], - "head": "head-oid", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "hasUpstream": true, - "upstreamName": "origin/feature" - } - } - } - }, "4175f142a784": { "name": "repo.list#1", "ordinal": 4, @@ -488,6 +428,57 @@ } } }, + "7ab1473c6d87": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/dev", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "8601995022f5": { "name": "git.status#1", "ordinal": 1, @@ -774,132 +765,132 @@ { "id": "sc-changes-loaded.normal:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.result-absent:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "076cb949f8e2", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "076cb949f8e2", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.result-null:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "4175f142a784", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "4175f142a784", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.inner-ok-missing:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "3489c9e5444b", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "3489c9e5444b", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.inner-false-string-error:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "1f09dd1eb6a5", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "1f09dd1eb6a5", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.inner-false-object-error:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "d6ac47f9876c", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "d6ac47f9876c", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.outer-refused:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "23e805bab835", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "23e805bab835", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.outer-refused-no-message:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "2ab58c7a7b52", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "2ab58c7a7b52", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.method-not-found:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "08e43eb9ca30", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "08e43eb9ca30", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.transport-rejection:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "24865b731030", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "24865b731030", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, { "id": "sc-changes-loaded.transport-rejection-no-message:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "ffc1ca2dfdd1", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "ffc1ca2dfdd1", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json index a3578fa6780..4d8e8ed3b86 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json @@ -1,69 +1,9 @@ { + "goldenFormatVersion": 6, "operation": "source-control.changes-load", "family": "git.changes-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "d40c9235948cb1cd85bec5451ce0f4183074e0a12a35834d8c911c35e99a6599", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "02adc1911747": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/dev", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "02d5719960a0": { "name": "source-control.status-load-success", "ordinal": 6, @@ -135,6 +75,58 @@ } } }, + "1d461278a0ae": { + "branchCompareState": { + "kind": "ready", + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + }, + "screenState": { + "kind": "ready", + "status": { + "branch": "feature", + "entries": [ + { + "added": 4, + "area": "unstaged", + "path": "src/app.ts", + "removed": 1, + "status": "modified" + }, + { + "area": "staged", + "oldPath": "src/older.ts", + "path": "src/old.ts", + "status": "renamed" + } + ], + "head": "head-oid", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "hasUpstream": true, + "upstreamName": "origin/feature" + } + } + } + }, "21b68b754dce": { "name": "worktree.show#1", "ordinal": 3, @@ -266,58 +258,6 @@ } } }, - "35fa5a183b27": { - "branchCompareState": { - "kind": "ready", - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - }, - "screenState": { - "kind": "ready", - "status": { - "branch": "feature", - "entries": [ - { - "added": 4, - "area": "unstaged", - "path": "src/app.ts", - "removed": 1, - "status": "modified" - }, - { - "area": "staged", - "oldPath": "src/older.ts", - "path": "src/old.ts", - "status": "renamed" - } - ], - "head": "head-oid", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "hasUpstream": true, - "upstreamName": "origin/feature" - } - } - } - }, "3889c135c3a2": { "name": "repo.baseRefDefault#1", "ordinal": 10, @@ -392,6 +332,57 @@ } } }, + "7ab1473c6d87": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/dev", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "7f93fced3c08": { "name": "repo.baseRefDefault#1", "ordinal": 9, @@ -805,12 +796,12 @@ { "id": "sc-changes-loaded.normal:settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index ce4246cb49b..44991a5d80b 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.commit-message", "family": "git.commit-message-ai", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "22ea5279155ecf749aaab521ffd570221ac3169b177fc1daf85ef93a49d38260", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "021e6483c4ec": { "name": "git.generateCommitMessage#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json index 32b2fd268db..64260b8a2fd 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.history-commit-files", "family": "git.history-commit-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "fc12c33acd3f3a34cbdb5893204be009bb089e2383a715fea4151c912de11ab0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a26f0498c96": { "name": "git.commitCompare#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json index 675c778ab31..c12fa5eab1a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.history-commit-files", "family": "git.history-commit-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "52610bb18381d371e479a5bef4a160814547baa355104059949b1c35b87342bc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "078e1d7e974d": { "commitRow": "unrendered", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index 092dd3ae535..ccaafb2a246 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.git-history", "family": "git.history-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "86254ed87ad3427d6ee4631d7348075039ba2d4d7496d59f27f03f78580f35a1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e92a08f8380": { "name": "git.history#1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index c5b8fa1ccec..b4d02fc4eb6 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.remote-prerequisite", "family": "git.remote-prerequisite", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "5009c22df7e74a850bcea41fc110ea7d7eb4bdada623837279f32eaa5149a9b8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00e8a3bac22f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index d69605a772c..d87c1bbb8fd 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "485b2751006ee8fb4df28b228ea7adda85779feae83974eb0f7e795e31c500a1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0dbefdbf195a": { "name": "git.status#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json index aa34ebfeea0..6b4169b8039 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "0c8f09683408a919dd3ac2b7cd12197d8745882627134cde95ecee209575b027", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02901e46c7a3": { "reply": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json index e9d6280e362..4ec572e7f3e 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "4f2b61df5e59d2efe81d467214a78132654035cbcb4d929385410b53f735c9fe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06ba1c859547": { "reply": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json index cecc525a4ca..c4fc6f16fdd 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "04b2052cce6f24d9e208c992f204355639409ddc9793b3044b394c0e38d3c284", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "14120b04af8e": { "name": "github.project.updateIssueCommentBySlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json index 294c5a2695b..ec6e65cd12a 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "8f849437158296753a84a75dfdbaf69852bcdb6cdc6e8205496961ec16e4be21", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "10229267ce84": { "name": "github.project.updateIssueCommentBySlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json index ce8130e211c..2c3a7daae03 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "77eba7deff3f15e64795cdcbefd009abfcf2379e7293eefb1f154ca1e99f4d5d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0828ac16da32": { "name": "github.resolveReviewThread#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json index bc31d8f4854..39de7bf391b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "2235d05e0d2c6f1a9518cfdd76870e303cccd35289d6a44334147b6a5b6b675e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347a5b67d44": { "auto-merge": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json index 2030a3897ea..fa2d259853f 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "2cea87c339b35daf62c963092c01c6379db43f2ee4ca5cb2b5f817975b0caf65", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0413dc160235": { "name": "github.removePRReviewers#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json index 3fa4c425789..826c1b369e2 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "78de11de556c91782590725819b22821732a12d3769f493d165c80bc7fcc1f53", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "035af8a295e0": { "auto-merge": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json index 9bdbddba0a7..a88f856db4b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "ac7e91b4d35021eca63af8ce01f9a2c7959109e4cb824009881437cb94dbfe82", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "053eb7126f9a": { "auto-merge": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json index f11f6dd43a5..a617c8b6f65 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "a85682e5009d634bf468b8dbe4f35a957988754ed9c2b07d57ad475c1590d1f6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04a837f2d497": { "auto-merge": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json index 52377ce2bb4..72e74129965 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "30f321fcbfaf09505bc6e18e64c09ca49c0dcdb7c5e12c4e27c2429b3e1066ca", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04c9f7782b94": { "auto-merge": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json index e6031d2899b..b862d4481e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "507743a5925a37be32156b3d8df83ddb8e08c262d2c2f44bbd117dee4672bbc5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "012aaf2806b6": { "name": "github.listAssignableUsers#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json index 8a746b7d508..bbae1d3abdb 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "6efca320cf31a1de988110004a9fb7b67fdad279a789e046ad6b5141b66e5bf1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0077514d4277": { "assignable": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json index aedb53ac141..e09d7f6a89f 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "c77d7c7a05ecb9b27180ef28ca63a28e1c1ca42db2bb54a468699da77281ae35", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "083bb0224d9d": { "name": "github.prForBranch#1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json index 60936c24937..7679028d753 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "3b3d3032b992fc7461b13de8a42512affa12fb018ad583898179a9934c13b414", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00308d923db4": { "hosted-review": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json index 890ffd08100..d084bfa866b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "4ebe865f874b0b4a813860ec7356b8dc214ea02f0a9036cb003efe863d89b83e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "007a6464a6ba": { "hosted-review": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json index 7c620ace6af..9fe6243e94b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "bc3350efc030f824fc21046aea8c6dc9a46993b6c75613c66874fde59af9171a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "069a9c97e09d": { "check-details": { diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json index 84e3d8d7ec4..046a715550c 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "9171e982babc4e56852fe35bafa7a9f3aeda2be5bd4648f29aaa04ca7119d5d2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0370db1ceeeb": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json index 7b869bba869..17986ba6c0e 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-title-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "fe91d5518501a078dff3010e74c4b9d70122f88a629e384336cd1b6a84de36a8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0400bbb4177c": { "title": { diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json index 5e3e117809b..8258eaf4432 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "home.host-accounts", "family": "home.host-accounts", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", - "scenarioSha256": "047e4c8fb2c4b374406658ec3954ac00fda96928bdd999a33ab9867284ffb4a4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11f6bbd8152f": { "name": "accounts.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json index 84817f85a31..fb6751f49c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "home.host-stats", "family": "home.host-stats", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", - "scenarioSha256": "5518e08c1b20f0ddd4cb6bc81ff9af032b1a38daf24f3d3497bac5df8b2d0ec5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1ac8bfa526f1": { "name": "stats.summary#1", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json index 8e35b0ac7e3..2e6a1ef3237 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.host-refresh", "family": "host-worktree-refresh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "0604294e541a4b2c73e0501cfc393c84384342ffcae286211a657ca5bc5892b7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "121f5eaa52a4": { "name": "fetchWorktrees", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json index 7cbe3edb79d..e1236d11ce9 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.host-refresh", "family": "host-worktree-refresh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "6d03d17a5711db33473611858b225e2ac42fe33d2a982fbb0defdbd1dce037d6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "121f5eaa52a4": { "name": "fetchWorktrees", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json index 94c83f0f737..ca86af416fc 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.host-refresh", "family": "host-worktree-refresh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "f36f11b3d69397bd98651fdad4614a4115098e23667e4dfc397c1d9ff2dc3186", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "121f5eaa52a4": { "name": "fetchWorktrees", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json index 45f37cca989..14048b7b225 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.host-refresh", "family": "host-worktree-refresh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "a70fa323de86b706f05818f321095349ed55b265804ec0ebb54419314a3ca612", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "121f5eaa52a4": { "name": "fetchWorktrees", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index 6d3c7f6f94f..58dd15888a8 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.view-settings", "family": "host.view-settings", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", - "scenarioSha256": "a9a3191e2e8c36870ce2769a7bb972813f435267fdc6ed9e42632a57a227bbd6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0078bce46c03": { "name": "ui.set#1", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index 0e7def82628..8d43fe96a95 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.view-settings", "family": "host.view-settings", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", - "scenarioSha256": "30cbeff90a845ab5dd576e302156e859338357b608e87e9fadeddf18ae93d9ca", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0078bce46c03": { "name": "ui.set#1", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index 404a2f89f26..01fccf7540c 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.worktree-actions", "family": "host.worktree-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", - "scenarioSha256": "7e842584620018d5ec5560711d63a472302e8da80cfe65dfcd2952aebb509af2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "047f5e4dfc2b": { "name": "optimisticActiveWorktreeIdentity", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index 04f692cca20..254aba02e3b 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.worktree-actions", "family": "host.worktree-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", - "scenarioSha256": "c47656a4ca21762e4b5a247ddf9a96efb746bec81e942ffa308a774bab1449e2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02715bd04503": { "name": "worktrees", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index 5098386c4f5..cf1b5d1bd2c 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "host.worktree-actions", "family": "host.worktree-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", - "scenarioSha256": "054f1b1380fc6cfd4b0f4a85d6f143822a12a0a732d550dd85eef64a6556b3ba", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01ee62307265": { "name": "worktree.set#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index e1b7fa432b8..6c45696bd94 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "fcf5cdc7388457156dd81fe28a470f42fbabac7435ec5572cb19e209f410ca84", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0122a4affef8": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index 2722a3dc179..722dfadefc8 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "26e666a57805f354602a5b3906a691b10c8d6db66c77acc96c67153279c515a7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0122a4affef8": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index e57ec82457a..2f34df916b6 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "08c25b6cb5bc12a7f67e858f229d15cf66b98b2ad4601b11f18c4c03f4a59669", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0122a4affef8": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index 4a2305bcd49..09966f73f9d 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "ff9d1bfd6337607f3d3e8162692b589ecea4a32ae01b5ebb3c602f8f0a55642c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index f946583aa4f..a8824b4c27c 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "efeb9b248aeb98fac71c043d50afe0036cf804d3c11edfccd4e050fe8f3d8f9b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index 7463a1bc89b..59fd658969c 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "7b0d9ddcb8df83fc4e465aa6b0dcf05aa0d8f266cd4bb8651969cb8321bcf549", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index 627f7488bde..8cc458fa406 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "72c1f08739db1c0dfcd48adffaca582a3596116c1c377f95f7dab8b08b7e6cdc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index 831261fe71b..2bf2277967a 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "1f002f900c1a3c92e8f7c72261579ee5015ec1529c003a1b32bcf3eaf98b672d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index c9180917cc6..21d3ab46d0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "3a6487a07457e0e5aa6fc3fccfa43687acfb06d94334e621081728de937e4e8d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index b7a96e01c98..8dd9f6b9d69 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "7355c45a707fa8a31f0999c4805a5b1dace4c65b727e711231f784b2f92c05ff", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01bc4ad46170": { "outcome": { diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index 3e26f5eb9e9..c55325f6c50 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "45cda6757b76399d282d4b07992dab21bbb8236faadedba5e92eab8818e886bf", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01bc4ad46170": { "outcome": { diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index e701009162c..644568f0ab1 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "0ca08d5e70e1780a6ee5c919491dcddb062a22623f803e9960a329825f274cbe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0186466201d2": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index 85f85213377..67ecaa83949 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "658eb7bbf63b3a4b38eca0b1733e523962b6b6943644d65aab6f5c7e62534d6a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index edbb82cccad..d736abfc269 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "75ba135731290bf734a5eef0b65f9ad8b7cac453c4e2006faac88a5da9dbe3a3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index 15bc63967ae..c18b016bb59 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "beb1161b98ffde8c5f1128e843766a1da3182d195f1f0a9012e12e5318ae01bc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index 190bdc01828..90b2605b97e 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-eligibility", "family": "hostedReview.eligibility", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "6c4de90e2617d204e82ca5e65eb17fc397acbcbb9dc0ec18594d2a7739e3528b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "067b33e5b1a9": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index b0453b1c0fc..87e81cae2a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "4f6472fb7add960be9bcc8596a748264d7cb0755a782ebe9e85753ab1d1d5710", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "003a57e2bf31": { "files": ["alpha.ts"] diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index bc4f8e5dac8..65c861032eb 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "048c3ec55ec67d09d9b02e17822f1154adca577e57ffe6d3059102d552d2f759", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13057f3a548c": { "name": "files.searchPaths#3", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index d71ede67479..53e312f8d9a 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "ad458a3407e3f1303343b46a1308b43535abef2c9ed2f68db59157db5b91daa1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c55dafe88ba": { "name": "files.list#2", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index 4c50612fc18..b40c8029557 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.file-inventory", "family": "legacy-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", - "scenarioSha256": "52742d894d0ea53db89729101664a393b10794d9c2d2fe7b40b020643a13af81", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "32c9018052ba": { "name": "files.searchPaths#1", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index e6ff652f014..f0ad8d3afd8 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.issue-detail", "family": "linear-detail-barrier", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "8e00afc85e5b82d75bedecea0c748a3c8658cfc8545650e755c03f51fdc932d6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056f47b204a": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index 1dcbcdbfe1b..05b6ebdce93 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.issue-detail", "family": "linear-detail-barrier", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "40289acce4a3542773f74681d255d67cfddadf6c42317928d6728f26a76f6cfb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056f47b204a": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json index 7088f794abf..508cf2e2fa9 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.select-workspace-picker", "family": "linear.select-workspace-picker", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", - "scenarioSha256": "c7de1f6fc0895d4ddf1b87a83859da46c583f098e058f14c8f85d48120c80c40", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "028532235ade": { "name": "linear.selectWorkspace#1", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json index 9da3e6beaf7..cd24b335593 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "f3fa2738875e15b640f21e56d3a0628333cd5b271242314b9c328822eec01d34", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c439cbde351": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json index 04d990d591c..76ffdd891f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "24b9ef7c06386b8af8303cfb196d2f652e46759b40aadb7b6af5144c964ea179", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c439cbde351": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json index 39f2a2e6386..e24e664cd23 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "e13633e6b76dce4aec343c391359adfd39430e91af1b183140fe4423316c81e1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c439cbde351": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json index 1bc86e91896..fccc2168be2 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "0bbbff1a914ba146777515d6d971144cc62f29f2d484a264513a1ce0f48be96a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c439cbde351": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json index 151808d673b..1584d311177 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "2ed321bfdc419635734c37b0a1cc4f85d6d92766846e25a37bd547dfa3af8f72", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0111c2c37f18": { "name": "worktree.show#2", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json index 5126ef54e29..b79e1f2de22 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.live-worktree-name", "family": "live-worktree-name", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", - "scenarioSha256": "ab7efe1dc6ef2ddfffa69c88bcc936f43393968a2281bc0ebfc864be650e7af1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06fe646d5269": { "name": "worktree.show#3", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json index 7573ef7cec0..800c7205efa 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "9de83f3ad2d85d1304a6ff00046b128a97318b3d859c78aa9acde73d9419aabe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ec8dc44128d": { "name": "mobileWeb.bundle.chunk#2", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json index 43a231817bd..811676c5e83 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "b15dbbc7f1d9ecc6918c9b9aec395fca93108e39f6b02c82f559dc060383a03b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ec8dc44128d": { "name": "mobileWeb.bundle.chunk#2", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json index 29fba15b4da..b9901f76af6 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "89539ae4d46c4fd7d93f06e464da91ebfb6da6abad4eda03f67c3bf727c8731f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ec8dc44128d": { "name": "mobileWeb.bundle.chunk#2", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json index fe3ed6f7f9d..bb0f1d0f9ce 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "f453e5e35d31bb2f1638ae68d97db16a0955375d3863e124e2aa57f7d1fa292c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04c76c033ffb": { "assets": { diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json index 4abd8f4e375..ef306e7526f 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-manifest", "family": "mobileWeb.bundle-manifest", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "8f5211fd3de1d77d3ea139f22bd6fc6119c72c08aa016f252a0df14bedf23ab3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12e970df3db8": { "outcome": "failed: refused: outer refused" diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json index 70d9caacc86..2f2efe3b363 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-paste", "family": "nativeChat.image-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "165402f60bc723edb2a867f09573fdae11a7c2ee4ef2be891916cfeb8aab21a0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json index bda0f2377a6..0b6711a69b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-paste", "family": "nativeChat.image-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "443fb50425d5cdbf0b289c6c051732090f165abba9b1ba4c39b712a459c612c9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "058919576f95": { "name": "terminal.send#2", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json index 6ca1f29ca74..065e8b3a8d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-upload", "family": "nativeChat.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "0478b692709ef0fe3cd75bf0d47fc27fcbb50ebc779e231537ba36638a0125f8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ff6f55894de": { "failure": "transport failure", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json index de35c7e8e9d..e507e80b295 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.session-option-pick", "family": "nativeChat.session-option-pick", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "1305f50c6e838d89d0a9e65d9011d2a532a2f75f7b3aea73f9e33330214f5724", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0aa1124bf746": { "settled": "settled" diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json index 4828a3c136a..aeb2482770b 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "860d3a80815ec68dc3fe2891a69888b80ec60fa205940e31f14643fb1097ead5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c75127e9c76": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json index e27b8dedce8..02c151cf798 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "c4193d972250dbe72907dcd32b8300b22986edfa5eeb651268c3838835177c64", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "088c6fb2da59": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json index 5c96345be12..d7f4875f654 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "e13d3072f9a0ff4458ec4231013d29f7131144b52089914cd08914652a2e136b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02230dcfa6da": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json index 6906e28b4be..0f0359a6065 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "aca5a21c0928ca5e84aac346f543a6840691c92b124c845cd01e3f2de553ea3d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "18a100fb6bae": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json index 2d604afd9c4..a80ea44fca1 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "53c8aa2ecfc045a50180dc707e353e22eef8511c0815ebacd3bda8cfb69d65c0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "18a100fb6bae": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json index 2a43ab0c76e..2731a3a4e82 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "afd3985ecdc07a1880aaee81739432633a1e87ec2de2c02166f43a6c78cb493b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "18a100fb6bae": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json index afae0c3d35a..66b2b21f409 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.display-test-screen", "family": "notifications.display-test-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", - "scenarioSha256": "b7a862c0de7dd4efcdf3f4db7c5aeda6b765ab58498f40f3b21232264758b16f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "026965874dd4": { "name": "notifications.testPush#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json index e06cd59edc4..2ebd91faceb 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.push-dismissal", "family": "notifications.push-dismissal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", - "scenarioSha256": "9ab21cda2ac956c70ddd23fe589778bbb46333314508cf92770d668ba59d5a90", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01d3e9d33757": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json index 0792eb91391..0ac2a92e315 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json @@ -1,19 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "notifications.push-registration", "family": "notifications.push-registration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", - "scenarioSha256": "c77a518d35203370669fea20f7669d7695a2ec851f22b220a8be417c546efeab", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "069cda9739b6": { + "0cf6d7d55723": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -24,6 +15,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -46,196 +38,11 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } + "ok": true } } }, - "2a4ec77775e2": { - "name": "notifications.registerPush#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "notifications.registerPush" - }, - { - "name": "params", - "value": { - "filter": { - "onlyWhenDesktopAway": true, - "sound": true - }, - "platform": "ios", - "token": "apns-token-1" - } - }, - { - "name": "options", - "value": { - "failWhenDisconnected": true, - "timeoutMs": 5000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": { - "message": "inner refused" - }, - "ok": false - } - } - } - }, - "37b76f37333f": { - "name": "notifications.registerPush#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "notifications.registerPush" - }, - { - "name": "params", - "value": { - "filter": { - "onlyWhenDesktopAway": true, - "sound": true - }, - "platform": "ios", - "token": "apns-token-1" - } - }, - { - "name": "options", - "value": { - "failWhenDisconnected": true, - "timeoutMs": 5000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "70a555b8bb94": { - "name": "notifications.registerPush#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" - }, - "722a8fdf7d4f": { - "name": "notifications.registerPush#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "notifications.registerPush" - }, - { - "name": "params", - "value": { - "filter": { - "onlyWhenDesktopAway": true, - "sound": true - }, - "platform": "ios", - "token": "apns-token-1" - } - }, - { - "name": "options", - "value": { - "failWhenDisconnected": true, - "timeoutMs": 5000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } - }, - "79ed32a5bb02": { - "name": "notifications.registerPush#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "notifications.registerPush" - }, - { - "name": "params", - "value": { - "filter": { - "onlyWhenDesktopAway": true, - "sound": true - }, - "platform": "ios", - "token": "apns-token-1" - } - }, - { - "name": "options", - "value": { - "failWhenDisconnected": true, - "timeoutMs": 5000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "registered": true, - "registrationId": "registration-1" - } - } - } - }, - "7ed3d39f0607": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": false - }, - "84e5ca07cb7a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": true - }, - "89a10bdc733c": { + "121001ebebd2": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -246,6 +53,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -273,7 +81,7 @@ } } }, - "8fcbab9ed2bf": { + "33bf276279a0": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -284,6 +92,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -314,7 +123,7 @@ } } }, - "93d5e9575318": { + "365e8d578191": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -325,6 +134,134 @@ { "name": "params", "value": { + "apnsEnvironment": "production", + "filter": { + "onlyWhenDesktopAway": true, + "sound": true + }, + "platform": "ios", + "token": "apns-token-1" + } + }, + { + "name": "options", + "value": { + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "5831e42facd9": { + "name": "notifications.registerPush#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "notifications.registerPush" + }, + { + "name": "params", + "value": { + "apnsEnvironment": "production", + "filter": { + "onlyWhenDesktopAway": true, + "sound": true + }, + "platform": "ios", + "token": "apns-token-1" + } + }, + { + "name": "options", + "value": { + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "692c015040bb": { + "name": "notifications.registerPush#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"apnsEnvironment\":\"production\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" + }, + "69d7402810cd": { + "name": "notifications.registerPush#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "notifications.registerPush" + }, + { + "name": "params", + "value": { + "apnsEnvironment": "production", + "filter": { + "onlyWhenDesktopAway": true, + "sound": true + }, + "platform": "ios", + "token": "apns-token-1" + } + }, + { + "name": "options", + "value": { + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "721a12a73823": { + "name": "notifications.registerPush#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "notifications.registerPush" + }, + { + "name": "params", + "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -348,14 +285,20 @@ "value": { "error": { "code": "refused", - "message": "" + "message": "outer refused" }, "id": "frame-1", "ok": false } } }, - "af33feca5244": { + "7ed3d39f0607": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": false + }, + "810a020dae97": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -366,6 +309,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -388,7 +332,58 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true + "ok": true, + "result": { + "registered": true, + "registrationId": "registration-1" + } + } + } + }, + "84e5ca07cb7a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": true + }, + "a812351e4401": { + "name": "notifications.registerPush#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "notifications.registerPush" + }, + { + "name": "params", + "value": { + "apnsEnvironment": "production", + "filter": { + "onlyWhenDesktopAway": true, + "sound": true + }, + "platform": "ios", + "token": "apns-token-1" + } + }, + { + "name": "options", + "value": { + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } } } }, @@ -427,7 +422,7 @@ } } }, - "d56fde1e24ad": { + "d2314e9574ed": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -438,6 +433,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -455,13 +451,18 @@ } ], "settlement": { - "status": "rejected", + "status": "fulfilled", "startedAt": 0, "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } } } }, @@ -478,7 +479,7 @@ "register": false, "unregister": true }, - "f34c0fb6d568": { + "f1a6149e883c": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -489,6 +490,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -512,7 +514,7 @@ "value": { "error": { "code": "refused", - "message": "outer refused" + "message": "" }, "id": "frame-1", "ok": false @@ -526,8 +528,8 @@ { "id": "notifications-push-registered.normal:settled", "observation": { - "sender": ["79ed32a5bb02", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -539,8 +541,8 @@ { "id": "notifications-push-registered.result-absent:settled", "observation": { - "sender": ["af33feca5244", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["0cf6d7d55723", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -552,8 +554,8 @@ { "id": "notifications-push-registered.result-null:settled", "observation": { - "sender": ["37b76f37333f", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["a812351e4401", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -565,8 +567,8 @@ { "id": "notifications-push-registered.inner-ok-missing:settled", "observation": { - "sender": ["069cda9739b6", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["365e8d578191", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -578,8 +580,8 @@ { "id": "notifications-push-registered.inner-false-string-error:settled", "observation": { - "sender": ["8fcbab9ed2bf", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["33bf276279a0", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -591,8 +593,8 @@ { "id": "notifications-push-registered.inner-false-object-error:settled", "observation": { - "sender": ["2a4ec77775e2", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["d2314e9574ed", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -604,8 +606,8 @@ { "id": "notifications-push-registered.outer-refused:settled", "observation": { - "sender": ["f34c0fb6d568", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["721a12a73823", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -617,8 +619,8 @@ { "id": "notifications-push-registered.outer-refused-no-message:settled", "observation": { - "sender": ["93d5e9575318", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["f1a6149e883c", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -630,8 +632,8 @@ { "id": "notifications-push-registered.method-not-found:settled", "observation": { - "sender": ["722a8fdf7d4f", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["5831e42facd9", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -643,8 +645,8 @@ { "id": "notifications-push-registered.transport-rejection:settled", "observation": { - "sender": ["d56fde1e24ad", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["69d7402810cd", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" @@ -656,8 +658,8 @@ { "id": "notifications-push-registered.transport-rejection-no-message:settled", "observation": { - "sender": ["89a10bdc733c", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["121001ebebd2", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "7ed3d39f0607", "unregister": "84e5ca07cb7a" diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json index dcf41dad6a0..e8b36a9a208 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.push-registration", "family": "notifications.push-registration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", - "scenarioSha256": "bb772b4b000644f18b48dcf78b05863b30906bcf588824d94eff086014fa69e8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08ed1d53c93c": { "name": "notifications.unregisterPush#1", @@ -84,10 +75,10 @@ } } }, - "70a555b8bb94": { + "692c015040bb": { "name": "notifications.registerPush#1", "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"apnsEnvironment\":\"production\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" }, "72c81945ccec": { "name": "notifications.unregisterPush#1", @@ -125,7 +116,13 @@ } } }, - "79ed32a5bb02": { + "7ed3d39f0607": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": false + }, + "810a020dae97": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -136,6 +133,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -166,12 +164,6 @@ } } }, - "7ed3d39f0607": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": false - }, "83a2635d8885": { "name": "notifications.unregisterPush#1", "ordinal": 3, @@ -476,8 +468,8 @@ { "id": "notifications-push-registered.normal:settled", "observation": { - "sender": ["79ed32a5bb02", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -489,8 +481,8 @@ { "id": "notifications-push-registered.result-absent:settled", "observation": { - "sender": ["79ed32a5bb02", "ad8a7aeb3530"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "ad8a7aeb3530"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -502,8 +494,8 @@ { "id": "notifications-push-registered.result-null:settled", "observation": { - "sender": ["79ed32a5bb02", "48e9da47a24b"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "48e9da47a24b"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -515,8 +507,8 @@ { "id": "notifications-push-registered.inner-ok-missing:settled", "observation": { - "sender": ["79ed32a5bb02", "910c7024cc8e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "910c7024cc8e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -528,8 +520,8 @@ { "id": "notifications-push-registered.inner-false-string-error:settled", "observation": { - "sender": ["79ed32a5bb02", "08ed1d53c93c"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "08ed1d53c93c"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -541,8 +533,8 @@ { "id": "notifications-push-registered.inner-false-object-error:settled", "observation": { - "sender": ["79ed32a5bb02", "a0599f5087e2"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "a0599f5087e2"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" @@ -554,8 +546,8 @@ { "id": "notifications-push-registered.outer-refused:settled", "observation": { - "sender": ["79ed32a5bb02", "ae91e9aebf2b"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "ae91e9aebf2b"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "7ed3d39f0607" @@ -567,8 +559,8 @@ { "id": "notifications-push-registered.outer-refused-no-message:settled", "observation": { - "sender": ["79ed32a5bb02", "72c81945ccec"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "72c81945ccec"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "7ed3d39f0607" @@ -580,8 +572,8 @@ { "id": "notifications-push-registered.method-not-found:settled", "observation": { - "sender": ["79ed32a5bb02", "b2780dfde374"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "b2780dfde374"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "7ed3d39f0607" @@ -593,8 +585,8 @@ { "id": "notifications-push-registered.transport-rejection:settled", "observation": { - "sender": ["79ed32a5bb02", "83a2635d8885"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "83a2635d8885"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "7ed3d39f0607" @@ -606,8 +598,8 @@ { "id": "notifications-push-registered.transport-rejection-no-message:settled", "observation": { - "sender": ["79ed32a5bb02", "97af7701f5db"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "97af7701f5db"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "7ed3d39f0607" diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json index 51f9cab8a2d..2d3bc939299 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "a28912c9abb97a227904723ff0de8162de31fce66c056da1813e0c18f6e01ccf", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "061b247f2805": { "name": "host-saved", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json index eb571950293..8b672e93d55 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "ccd2ef7c617d13bdf5987f5f89fed6917206205268633b9ef061b92c3580d672", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "061b247f2805": { "name": "host-saved", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json index 99d22ea0bd3..92c358802d5 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "399fa8b85d9c2fc341ea2284a54aed278fd1a5b19a84cc9284c29d5a583bc519", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "061b247f2805": { "name": "host-saved", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json index ea72c6502d2..41857ce3cae 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "14931cc23cd0e6d850f596c880014c606834898acbeee4abff3dc83a94b0c6c0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "061b247f2805": { "name": "host-saved", diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index 668b9c84bf9..e75b3703758 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "project.update-metadata", "family": "project-explicit-false", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "926f0d8c37a33d465bf3a04f056600cfc9f1669b1eca7e968aa1a1f797a74c61", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0053a93946cd": { "name": "github.project.updateIssueBySlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json index 7c4b581d9c3..3d5eb2aef4b 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.credential-rotation", "family": "relay.credential-rotation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "042d0f9ef57e2a18bf661b79f2f8f92a12125dbc0fc65dd8605f8cd6f7059d10", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09d6d0f0cdd6": { "name": "pairing.getEndpoints#1", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json index 9eb068188c7..b6d1b61c6a4 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.credential-rotation", "family": "relay.credential-rotation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "530aa1f2ddc6fce10d485c8a160ae3e695250b2e80e1e7fc3e8dacb9f5117347", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12ac089c6078": { "name": "pairing.getEndpoints#2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json index fb690cf2693..8706ba52b22 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.credential-rotation", "family": "relay.credential-rotation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "18b1855f354c23cd5bb7af0db698f0a23d28208c3c6b7347f667bf6cd3ed612f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f56ed90f21": { "outcome": "failed: The host sent a reply this app could not read (pairing.provisionRelay)", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json index ac4780fa50f..3ae62c75e1d 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.direct-upgrade", "family": "relay.direct-upgrade", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "03355bc2696d02fed125d9f0e24c6c26c8df2f3709c1c5f4412aaf9317cd41d4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0244f37a98cf": { "name": "pairing.getEndpoints#1", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json index 6d0198b9b6a..225cfb97de2 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.direct-upgrade", "family": "relay.direct-upgrade", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "d0c4dd34645308f30c0999ea74c16b53f20e9183832fdf016c3dc21434744b05", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06344bc54a38": { "name": "pairing.getEndpoints#2", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json index 2ea5793519b..fd1c999a6fd 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.direct-upgrade", "family": "relay.direct-upgrade", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "0fe163f405373adbb1913dddd79d6d596bf88d69fc27c824ba5a2cd4c1406446", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09e7feee37af": { "name": "pairing.provisionRelay#1", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json index 88d32e67d1d..d3ffbe1ba51 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.pairing-recovery", "family": "relay.pairing-recovery", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "5aaa104a652d4f10cd48ab742112cf59fca22f52bbf85ee3716505a9642fbe37", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1020838fc104": { "name": "pairing.getEndpoints#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json index b5dec27ab14..844586c01ce 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.browser-tab-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "6c606813584d6ac89251c725a556d717603acde405a8d45ef6d844e06f2da67a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0126fcbbbea0": { "name": "browser.tabCreate#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json index f5f1a42c141..eb26d99252a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "06bf92360e6985770c08a9e53be0f55b6e8ff120d4e6411dacc22e88d08cef32", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a3eb4eb0d76": { "name": "toast", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json index 4fc2a9a6a70..d6bb2cb426e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "17f87233352ff8e452f061f4558d58b44643efe49313162d4aad140343a1ead9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "134e6545bfe5": { "createError": "transport failure", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json index a2b045ea274..93d0d8cd792 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "5100bd674509d1d83b1e2594eee036af21ea14b12226185b44070555d1d43060", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "049e3dd3b1db": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json index 3c93c5850f6..9dae41ebaf8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "b9ca49e401df8710924f200f92a071bac2e120ed43df7be2cfb8a325ab1cd9cb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0d389bb9bac8": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index 301aba46f38..d8eb20e2e4d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "ae1572c1670073d0ac3d2d3abd0e7bee9910cbe4a0b09ecd941e887eeb1db165", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2977cbddd89f": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index a5b78204514..d9527dc5331 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "62930616f1017ffd4b74546c80b17f3aa3bcee52a6ad62ec144d95872fd31321", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1fc8075c4347": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json index 250a9e128c3..55973e71815 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-notes", "family": "session.diff-notes", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "e54684bad13aa8b8b4794e06351c709053b1c4c6d87776ecdbb1dd1b4406a694", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "106579ec0285": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index e7b9015c59d..a6a79b93bd9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "c583058382c949e977b7a1287895ed9ccb8cb408b684aef186b629976ff1da4d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02c4a9358dee": { "actionError": { diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json index 501976ecc14..bc15f2d60da 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "17e2b30594a2b37e82ff1976377722c2f1c3ae7f01857e50e010a2dd2e89da3a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a68c39a7af8": { "name": "worktree.show#1", @@ -79,11 +70,319 @@ "startedAt": 0 } }, + "15f193a5a1c7": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, + "16364e8e8caf": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "168d1f64ca9b": { "name": "git.status#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.status\",\"params\":{\"worktree\":\"id:repo-9::/w\"}}" }, + "16c1635cc4a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "1a192a0ea430": { "name": "worktree.show#1", "ordinal": 3, @@ -255,57 +554,6 @@ } } }, - "4a2489a297d0": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-5", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -554,134 +802,6 @@ } } }, - "e13943e37fc3": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "e39817462870": { "branchCompare": "unloaded", "diff": "unloaded", @@ -755,135 +875,6 @@ } } } - }, - "f880a1519497": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } } }, "recording": { @@ -909,7 +900,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -919,9 +910,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -933,7 +924,7 @@ "1a192a0ea430", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -943,9 +934,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -957,7 +948,7 @@ "f67a42b5e03c", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -967,9 +958,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -981,7 +972,7 @@ "34db668d16ed", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -991,9 +982,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1005,7 +996,7 @@ "a11de3388bcf", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1015,9 +1006,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1029,7 +1020,7 @@ "b901bfbb950d", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1039,9 +1030,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1053,7 +1044,7 @@ "460a6aebc491", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1063,9 +1054,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1077,7 +1068,7 @@ "0a68c39a7af8", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1087,9 +1078,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1101,7 +1092,7 @@ "f05efc9dfd61", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1111,9 +1102,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1125,7 +1116,7 @@ "2f4eadd5dfcb", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1135,9 +1126,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1149,7 +1140,7 @@ "c0e4f809b747", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1159,9 +1150,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json index 8fc3fe1fd41..de31c7a78b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "b99f51a5527e42a32ea9203ad75b16f9dd3cdcdc2a3ed235f6467ac1c7e3a4f3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00375023b67b": { "status": "fulfilled", @@ -193,6 +184,185 @@ "startedAt": 0 } }, + "15f193a5a1c7": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, + "16364e8e8caf": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "168d1f64ca9b": { "name": "git.status#1", "ordinal": 2, @@ -279,6 +449,135 @@ } } }, + "16c1635cc4a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "1cb6dd8e325b": { "name": "worktree.show#2", "ordinal": 5, @@ -635,57 +934,6 @@ } } }, - "4a2489a297d0": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-5", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -1371,134 +1619,6 @@ } } }, - "e13943e37fc3": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "e39817462870": { "branchCompare": "unloaded", "diff": "unloaded", @@ -1585,135 +1705,6 @@ } } }, - "f880a1519497": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "fee89d394a80": { "status": "fulfilled", "startedAt": 0, @@ -1901,7 +1892,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1911,9 +1902,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json index fda5a92cfb4..b769d259ab8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "b4627f9ac9bc090a2b48fd35f32d5dc3d66fefe0fb65abab75597ef2c73510ec", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bd4c9fb1332": { "name": "git.status#1", @@ -88,11 +79,319 @@ "message": "Update Orca desktop to review changes on mobile." } }, + "15f193a5a1c7": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, + "16364e8e8caf": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "168d1f64ca9b": { "name": "git.status#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.status\",\"params\":{\"worktree\":\"id:repo-9::/w\"}}" }, + "16c1635cc4a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "1cb6dd8e325b": { "name": "worktree.show#2", "ordinal": 5, @@ -177,57 +476,6 @@ } } }, - "4a2489a297d0": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-5", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -555,134 +803,6 @@ } } }, - "e13943e37fc3": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "e39817462870": { "branchCompare": "unloaded", "diff": "unloaded", @@ -754,135 +874,6 @@ } } }, - "f880a1519497": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "fa4ee3a35705": { "name": "git.status#1", "ordinal": 1, @@ -976,7 +967,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -986,9 +977,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json index 015d2cc0a13..f07b5df4611 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "c9f720134506b6db71b742c219abe736fca1f90070403d7c96df9396fd048b6f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "076cb949f8e2": { "name": "repo.list#1", @@ -110,11 +101,319 @@ "startedAt": 0 } }, + "15f193a5a1c7": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, + "16364e8e8caf": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "168d1f64ca9b": { "name": "git.status#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.status\",\"params\":{\"worktree\":\"id:repo-9::/w\"}}" }, + "16c1635cc4a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "1cb6dd8e325b": { "name": "worktree.show#2", "ordinal": 5, @@ -359,57 +658,6 @@ } } }, - "4a2489a297d0": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-5", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -591,268 +839,11 @@ } } }, - "e13943e37fc3": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "e39817462870": { "branchCompare": "unloaded", "diff": "unloaded", "snapshot": "unloaded" }, - "f880a1519497": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "ffc1ca2dfdd1": { "name": "repo.list#1", "ordinal": 4, @@ -909,7 +900,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -919,9 +910,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -933,7 +924,7 @@ "d7e7bd22e9f6", "076cb949f8e2", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -943,9 +934,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -957,7 +948,7 @@ "d7e7bd22e9f6", "4175f142a784", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -967,9 +958,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -981,7 +972,7 @@ "d7e7bd22e9f6", "3489c9e5444b", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -991,9 +982,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1005,7 +996,7 @@ "d7e7bd22e9f6", "1f09dd1eb6a5", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1015,9 +1006,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1029,7 +1020,7 @@ "d7e7bd22e9f6", "d6ac47f9876c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1039,9 +1030,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1053,7 +1044,7 @@ "d7e7bd22e9f6", "23e805bab835", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1063,9 +1054,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1077,7 +1068,7 @@ "d7e7bd22e9f6", "2ab58c7a7b52", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1087,9 +1078,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1101,7 +1092,7 @@ "d7e7bd22e9f6", "08e43eb9ca30", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1111,9 +1102,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1125,7 +1116,7 @@ "d7e7bd22e9f6", "24865b731030", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1135,9 +1126,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1149,7 +1140,7 @@ "d7e7bd22e9f6", "ffc1ca2dfdd1", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1159,9 +1150,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json index 8b53cc35b21..b7a4644aa63 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.diff-review", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "f17bb817f9a172e776f1814920d58abc7db122da9c49cfe3bbeaf217f82d70d7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0624baf0b1a6": { "status": "rejected", @@ -120,11 +111,319 @@ "startedAt": 0 } }, + "15f193a5a1c7": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, + "16364e8e8caf": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "168d1f64ca9b": { "name": "git.status#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.status\",\"params\":{\"worktree\":\"id:repo-9::/w\"}}" }, + "16c1635cc4a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branchCompare": { + "entries": [ + { + "added": 1, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "commitsAhead": { + "$rpc": "undefined" + }, + "compareRef": "feature", + "errorMessage": { + "$rpc": "undefined" + }, + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + }, + "branchError": { + "$rpc": "undefined" + }, + "comments": [], + "kind": "ready", + "reviewState": { + "completedAt": { + "$rpc": "undefined" + }, + "files": { + "branch\u0000branch\u0000\u0000src/old.ts": { + "filePath": "src/old.ts", + "key": "branch\u0000branch\u0000\u0000src/old.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "branch" + }, + "unstaged\u0000unstaged\u0000\u0000src/app.ts": { + "filePath": "src/app.ts", + "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", + "lastOpenedAt": { + "$rpc": "undefined" + }, + "lastSeenDiffIdentity": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "reviewDiffIdentity": { + "$rpc": "undefined" + }, + "reviewedAt": { + "$rpc": "undefined" + }, + "scope": "unstaged" + } + }, + "updatedAt": 1767225600000, + "version": 1 + }, + "status": { + "branch": "feature", + "conflictOperation": "unknown", + "entries": [ + { + "added": 3, + "area": "unstaged", + "conflictKind": { + "$rpc": "undefined" + }, + "conflictStatus": { + "$rpc": "undefined" + }, + "conflictStatusSource": { + "$rpc": "undefined" + }, + "oldPath": { + "$rpc": "undefined" + }, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "head": "head-sha-1", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "behindCommitsArePatchEquivalent": { + "$rpc": "undefined" + }, + "hasConfiguredPushTarget": { + "$rpc": "undefined" + }, + "hasUpstream": true, + "upstreamName": { + "$rpc": "undefined" + } + } + } + } + }, "1935bb137c1b": { "name": "worktree.show#2", "ordinal": 5, @@ -206,57 +505,6 @@ "isRpcDeliveryUnknown": false } }, - "4a2489a297d0": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-5", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "4a899e079b42": { "name": "repo.list#1", "ordinal": 7, @@ -613,268 +861,11 @@ } } }, - "e13943e37fc3": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "e39817462870": { "branchCompare": "unloaded", "diff": "unloaded", "snapshot": "unloaded" }, - "f880a1519497": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branchCompare": { - "entries": [ - { - "added": 1, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "commitsAhead": { - "$rpc": "undefined" - }, - "compareRef": "feature", - "errorMessage": { - "$rpc": "undefined" - }, - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - }, - "branchError": { - "$rpc": "undefined" - }, - "comments": [], - "kind": "ready", - "reviewState": { - "completedAt": { - "$rpc": "undefined" - }, - "files": { - "branch\u0000branch\u0000\u0000src/old.ts": { - "filePath": "src/old.ts", - "key": "branch\u0000branch\u0000\u0000src/old.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "branch" - }, - "unstaged\u0000unstaged\u0000\u0000src/app.ts": { - "filePath": "src/app.ts", - "key": "unstaged\u0000unstaged\u0000\u0000src/app.ts", - "lastOpenedAt": { - "$rpc": "undefined" - }, - "lastSeenDiffIdentity": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "reviewDiffIdentity": { - "$rpc": "undefined" - }, - "reviewedAt": { - "$rpc": "undefined" - }, - "scope": "unstaged" - } - }, - "updatedAt": 1767225600000, - "version": 1 - }, - "status": { - "branch": "feature", - "conflictOperation": "unknown", - "entries": [ - { - "added": 3, - "area": "unstaged", - "conflictKind": { - "$rpc": "undefined" - }, - "conflictStatus": { - "$rpc": "undefined" - }, - "conflictStatusSource": { - "$rpc": "undefined" - }, - "oldPath": { - "$rpc": "undefined" - }, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "head": "head-sha-1", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "behindCommitsArePatchEquivalent": { - "$rpc": "undefined" - }, - "hasConfiguredPushTarget": { - "$rpc": "undefined" - }, - "hasUpstream": true, - "upstreamName": { - "$rpc": "undefined" - } - } - } - } - }, "fa437a9d524b": { "name": "worktree.show#2", "ordinal": 5, @@ -969,7 +960,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1cb6dd8e325b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -979,9 +970,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -993,7 +984,7 @@ "d7e7bd22e9f6", "8f95d359947c", "0ea7669e1039", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1017,7 +1008,7 @@ "d7e7bd22e9f6", "8f95d359947c", "913f959499fd", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1041,7 +1032,7 @@ "d7e7bd22e9f6", "8f95d359947c", "b34cc46f308d", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1051,9 +1042,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1065,7 +1056,7 @@ "d7e7bd22e9f6", "8f95d359947c", "101b622a00da", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1075,9 +1066,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1089,7 +1080,7 @@ "d7e7bd22e9f6", "8f95d359947c", "9abe670ff788", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1099,9 +1090,9 @@ "1069be6ec9cc" ], "settlements": { - "snapshot": "f880a1519497" + "snapshot": "16c1635cc4a4" }, - "state": "e13943e37fc3", + "state": "16364e8e8caf", "effects": [] } }, @@ -1113,7 +1104,7 @@ "d7e7bd22e9f6", "8f95d359947c", "fa437a9d524b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1137,7 +1128,7 @@ "d7e7bd22e9f6", "8f95d359947c", "fba22678bda3", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1161,7 +1152,7 @@ "d7e7bd22e9f6", "8f95d359947c", "5e96b510bf9a", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1185,7 +1176,7 @@ "d7e7bd22e9f6", "8f95d359947c", "1935bb137c1b", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", @@ -1209,7 +1200,7 @@ "d7e7bd22e9f6", "8f95d359947c", "d7094b495ca7", - "4a2489a297d0" + "15f193a5a1c7" ], "payloads": [ "168d1f64ca9b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json index e04c8b3ee7e..798961d8947 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.markdown-disk-fallback", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "c4b6c9c21433876e2bb3822f289627e7c5607830559289d35cae066ff895fc23", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1814b29b1212": { "name": "files.read#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json index e34993cefcf..0be6a7323b6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.markdown-disk-fallback", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "42ddfcc6c8cac61e5891b72fbac9d04763fa9c52d0cb4f44e1044714a8a1ee77", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0d6b0adc036f": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json index f9d5c2d6778..4f3db43e51d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.markdown-save", "family": "session.markdown-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "389c6118f3137b78e88af6b901554b0331c652063a00d8ba3119e0883ce82f25", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13f8c99bcf0e": { "markdown": { diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index 63577bc9c28..1b0aad90967 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-page", "family": "session.native-chat-page", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", - "scenarioSha256": "8a6f6d37fd7dbc9e0da081565f24b270949306c47a874568874ec8ce2579076b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08ef53d83e8c": { "name": "nativeChat.readSession#1", @@ -31,11 +22,6 @@ "status": "ready", "transcriptLoading": false }, - "09f715780ff1": { - "name": "nativeChat.subscribe#1", - "ordinal": 1, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" - }, "1821d5c2646a": { "name": "nativeChat.readSession#1", "ordinal": 2, @@ -84,11 +70,6 @@ "status": "ready", "transcriptLoading": false }, - "1c6ad14c561f": { - "name": "nativeChat.unsubscribe#1", - "ordinal": 5, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, "2dd353a0e048": { "name": "nativeChat.readSession#1", "ordinal": 2, @@ -128,6 +109,11 @@ } } }, + "3e14b1ec5bde": { + "name": "nativeChat.unsubscribe#2", + "ordinal": 6, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, "41f222acdcb2": { "name": "nativeChat.readSession#1", "ordinal": 2, @@ -167,10 +153,10 @@ } } }, - "459bc33ee698": { - "name": "nativeChat.subscribe#2", - "ordinal": 4, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + "456781b97acc": { + "name": "nativeChat.subscribe#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, "63d9cc126261": { "name": "nativeChat.readSession#1", @@ -208,6 +194,11 @@ } } }, + "663babae1f1d": { + "name": "nativeChat.subscribe#2", + "ordinal": 4, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + }, "7b237e9824c8": { "crash": { "$rpc": "null" @@ -270,6 +261,11 @@ } } }, + "7eedfcf76fd3": { + "name": "nativeChat.unsubscribe#1", + "ordinal": 5, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, "8f91342d7840": { "crash": { "$rpc": "null" @@ -573,11 +569,6 @@ "startedAt": 0 } }, - "e488a88a0a50": { - "name": "nativeChat.unsubscribe#2", - "ordinal": 6, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -607,7 +598,7 @@ "id": "native-chat-page-earlier.prelude:subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -619,7 +610,7 @@ "id": "native-chat-page-earlier.prelude:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -631,7 +622,7 @@ "id": "native-chat-page-earlier.prelude:paging", "observation": { "sender": ["dadae0a56731"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -644,7 +635,7 @@ "id": "native-chat-page-earlier.normal:paged", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -657,7 +648,7 @@ "id": "native-chat-page-earlier.normal:re-subscribed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -671,7 +662,7 @@ "id": "native-chat-page-earlier.normal:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -686,11 +677,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -706,7 +697,7 @@ "id": "native-chat-page-earlier.result-absent:paged", "observation": { "sender": ["1821d5c2646a"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -719,7 +710,7 @@ "id": "native-chat-page-earlier.result-absent:re-subscribed", "observation": { "sender": ["1821d5c2646a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -733,7 +724,7 @@ "id": "native-chat-page-earlier.result-absent:replayed", "observation": { "sender": ["1821d5c2646a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -748,11 +739,11 @@ "observation": { "sender": ["1821d5c2646a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -768,7 +759,7 @@ "id": "native-chat-page-earlier.result-null:paged", "observation": { "sender": ["a92af671fd39"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -781,7 +772,7 @@ "id": "native-chat-page-earlier.result-null:re-subscribed", "observation": { "sender": ["a92af671fd39"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -795,7 +786,7 @@ "id": "native-chat-page-earlier.result-null:replayed", "observation": { "sender": ["a92af671fd39"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -810,11 +801,11 @@ "observation": { "sender": ["a92af671fd39"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -830,7 +821,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:paged", "observation": { "sender": ["9a114b6ce2df"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -843,7 +834,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:re-subscribed", "observation": { "sender": ["9a114b6ce2df"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -857,7 +848,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:replayed", "observation": { "sender": ["9a114b6ce2df"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -872,11 +863,11 @@ "observation": { "sender": ["9a114b6ce2df"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -892,7 +883,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:paged", "observation": { "sender": ["d163a9801643"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -905,7 +896,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:re-subscribed", "observation": { "sender": ["d163a9801643"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -919,7 +910,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:replayed", "observation": { "sender": ["d163a9801643"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -934,11 +925,11 @@ "observation": { "sender": ["d163a9801643"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -954,7 +945,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:paged", "observation": { "sender": ["9a3f65995021"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -967,7 +958,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:re-subscribed", "observation": { "sender": ["9a3f65995021"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -981,7 +972,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:replayed", "observation": { "sender": ["9a3f65995021"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -996,11 +987,11 @@ "observation": { "sender": ["9a3f65995021"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -1016,7 +1007,7 @@ "id": "native-chat-page-earlier.outer-refused:paged", "observation": { "sender": ["41f222acdcb2"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1029,7 +1020,7 @@ "id": "native-chat-page-earlier.outer-refused:re-subscribed", "observation": { "sender": ["41f222acdcb2"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1043,7 +1034,7 @@ "id": "native-chat-page-earlier.outer-refused:replayed", "observation": { "sender": ["41f222acdcb2"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1058,11 +1049,11 @@ "observation": { "sender": ["41f222acdcb2"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -1078,7 +1069,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:paged", "observation": { "sender": ["2dd353a0e048"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1091,7 +1082,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:re-subscribed", "observation": { "sender": ["2dd353a0e048"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1105,7 +1096,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:replayed", "observation": { "sender": ["2dd353a0e048"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1120,11 +1111,11 @@ "observation": { "sender": ["2dd353a0e048"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -1140,7 +1131,7 @@ "id": "native-chat-page-earlier.method-not-found:paged", "observation": { "sender": ["cea1d50a73fa"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1153,7 +1144,7 @@ "id": "native-chat-page-earlier.method-not-found:re-subscribed", "observation": { "sender": ["cea1d50a73fa"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1167,7 +1158,7 @@ "id": "native-chat-page-earlier.method-not-found:replayed", "observation": { "sender": ["cea1d50a73fa"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1182,11 +1173,11 @@ "observation": { "sender": ["cea1d50a73fa"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -1202,7 +1193,7 @@ "id": "native-chat-page-earlier.transport-rejection:paged", "observation": { "sender": ["63d9cc126261"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1215,7 +1206,7 @@ "id": "native-chat-page-earlier.transport-rejection:re-subscribed", "observation": { "sender": ["63d9cc126261"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1229,7 +1220,7 @@ "id": "native-chat-page-earlier.transport-rejection:replayed", "observation": { "sender": ["63d9cc126261"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1244,11 +1235,11 @@ "observation": { "sender": ["63d9cc126261"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -1264,7 +1255,7 @@ "id": "native-chat-page-earlier.transport-rejection-no-message:paged", "observation": { "sender": ["7ee0fd6f2bd8"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1277,7 +1268,7 @@ "id": "native-chat-page-earlier.transport-rejection-no-message:re-subscribed", "observation": { "sender": ["7ee0fd6f2bd8"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1291,7 +1282,7 @@ "id": "native-chat-page-earlier.transport-rejection-no-message:replayed", "observation": { "sender": ["7ee0fd6f2bd8"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1306,11 +1297,11 @@ "observation": { "sender": ["7ee0fd6f2bd8"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index b4aa358c816..430461becfc 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-page", "family": "session.native-chat-page", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", - "scenarioSha256": "de804d4fa5287b07be9079d21e0a3cfc7f22d0e4b8649f13a3465a30683a77c8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08ef53d83e8c": { "name": "nativeChat.readSession#1", @@ -31,10 +22,10 @@ "status": "ready", "transcriptLoading": false }, - "09f715780ff1": { - "name": "nativeChat.subscribe#1", - "ordinal": 1, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + "1693563703b9": { + "name": "nativeChat.unsubscribe#2", + "ordinal": 4, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" }, "197fa03857dd": { "crash": { @@ -49,30 +40,25 @@ "status": "ready", "transcriptLoading": false }, - "1c6ad14c561f": { - "name": "nativeChat.unsubscribe#1", - "ordinal": 5, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" + "2a5d879ed94b": { + "name": "nativeChat.subscribe#2", + "ordinal": 2, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, - "459bc33ee698": { + "3e14b1ec5bde": { + "name": "nativeChat.unsubscribe#2", + "ordinal": 6, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, + "456781b97acc": { + "name": "nativeChat.subscribe#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + }, + "663babae1f1d": { "name": "nativeChat.subscribe#2", "ordinal": 4, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" - }, - "53eaf803beb5": { - "name": "nativeChat.unsubscribe#1", - "ordinal": 3, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, - "5a78e2e003f7": { - "name": "nativeChat.subscribe#2", - "ordinal": 3, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" - }, - "70eb4a48d8d0": { - "name": "nativeChat.unsubscribe#2", - "ordinal": 5, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, "7b237e9824c8": { "crash": { @@ -100,6 +86,11 @@ "status": "ready", "transcriptLoading": false }, + "7eedfcf76fd3": { + "name": "nativeChat.unsubscribe#1", + "ordinal": 5, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, "813c942b745a": { "crash": { "$rpc": "null" @@ -124,10 +115,10 @@ "status": "ready", "transcriptLoading": false }, - "91a7795fa0ab": { + "96abfc4cef7e": { "name": "nativeChat.unsubscribe#1", "ordinal": 4, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" }, "9ec06f18374b": { "crash": { @@ -140,6 +131,11 @@ "status": "ready", "transcriptLoading": false }, + "a2b8cf4aa6d5": { + "name": "nativeChat.unsubscribe#1", + "ordinal": 3, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, "b09dcbf7cafc": { "crash": { "$rpc": "null" @@ -151,6 +147,11 @@ "status": "error", "transcriptLoading": false }, + "b84b4297f28e": { + "name": "nativeChat.subscribe#2", + "ordinal": 3, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + }, "c5cc569a5dda": { "crash": { "$rpc": "null" @@ -279,21 +280,6 @@ "startedAt": 0 } }, - "db2678a902bb": { - "name": "nativeChat.subscribe#2", - "ordinal": 2, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" - }, - "e488a88a0a50": { - "name": "nativeChat.unsubscribe#2", - "ordinal": 6, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, - "e893d19cf5f3": { - "name": "nativeChat.unsubscribe#2", - "ordinal": 4, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -338,6 +324,11 @@ }, "frame": "nativeChat.subscribe#1" } + }, + "fb84e5e8aefe": { + "name": "nativeChat.unsubscribe#2", + "ordinal": 5, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" } }, "recording": { @@ -347,7 +338,7 @@ "id": "native-chat-page-earlier.prelude:subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -359,7 +350,7 @@ "id": "native-chat-page-earlier.normal:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -371,7 +362,7 @@ "id": "native-chat-page-earlier.normal:paging", "observation": { "sender": ["dadae0a56731"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -384,7 +375,7 @@ "id": "native-chat-page-earlier.normal:paged", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -397,7 +388,7 @@ "id": "native-chat-page-earlier.normal:re-subscribed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -411,7 +402,7 @@ "id": "native-chat-page-earlier.normal:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -426,11 +417,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -446,7 +437,7 @@ "id": "native-chat-page-earlier.result-absent:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -458,7 +449,7 @@ "id": "native-chat-page-earlier.result-absent:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -471,7 +462,7 @@ "id": "native-chat-page-earlier.result-absent:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -484,7 +475,7 @@ "id": "native-chat-page-earlier.result-absent:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "5a78e2e003f7", "91a7795fa0ab"], + "payloads": ["456781b97acc", "b84b4297f28e", "96abfc4cef7e"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -498,7 +489,7 @@ "id": "native-chat-page-earlier.result-absent:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "5a78e2e003f7", "91a7795fa0ab"], + "payloads": ["456781b97acc", "b84b4297f28e", "96abfc4cef7e"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -512,7 +503,7 @@ "id": "native-chat-page-earlier.result-absent:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "5a78e2e003f7", "91a7795fa0ab", "70eb4a48d8d0"], + "payloads": ["456781b97acc", "b84b4297f28e", "96abfc4cef7e", "fb84e5e8aefe"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -527,7 +518,7 @@ "id": "native-chat-page-earlier.result-null:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -539,7 +530,7 @@ "id": "native-chat-page-earlier.result-null:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -552,7 +543,7 @@ "id": "native-chat-page-earlier.result-null:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -565,7 +556,7 @@ "id": "native-chat-page-earlier.result-null:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "5a78e2e003f7", "91a7795fa0ab"], + "payloads": ["456781b97acc", "b84b4297f28e", "96abfc4cef7e"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -579,7 +570,7 @@ "id": "native-chat-page-earlier.result-null:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "5a78e2e003f7", "91a7795fa0ab"], + "payloads": ["456781b97acc", "b84b4297f28e", "96abfc4cef7e"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -593,7 +584,7 @@ "id": "native-chat-page-earlier.result-null:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "5a78e2e003f7", "91a7795fa0ab", "70eb4a48d8d0"], + "payloads": ["456781b97acc", "b84b4297f28e", "96abfc4cef7e", "fb84e5e8aefe"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -608,7 +599,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -620,7 +611,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -633,7 +624,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -646,7 +637,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -660,7 +651,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -674,7 +665,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5", "e893d19cf5f3"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5", "1693563703b9"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -689,7 +680,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -701,7 +692,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -714,7 +705,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -727,7 +718,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -741,7 +732,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -755,7 +746,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5", "e893d19cf5f3"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5", "1693563703b9"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -770,7 +761,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -782,7 +773,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -795,7 +786,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -808,7 +799,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -822,7 +813,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -836,7 +827,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5", "e893d19cf5f3"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5", "1693563703b9"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -851,7 +842,7 @@ "id": "native-chat-page-earlier.outer-refused:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -863,7 +854,7 @@ "id": "native-chat-page-earlier.outer-refused:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -876,7 +867,7 @@ "id": "native-chat-page-earlier.outer-refused:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -889,7 +880,7 @@ "id": "native-chat-page-earlier.outer-refused:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb"], + "payloads": ["456781b97acc", "2a5d879ed94b"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -903,7 +894,7 @@ "id": "native-chat-page-earlier.outer-refused:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb"], + "payloads": ["456781b97acc", "2a5d879ed94b"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -917,7 +908,7 @@ "id": "native-chat-page-earlier.outer-refused:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -932,7 +923,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -944,7 +935,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -957,7 +948,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -970,7 +961,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb"], + "payloads": ["456781b97acc", "2a5d879ed94b"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -984,7 +975,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb"], + "payloads": ["456781b97acc", "2a5d879ed94b"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -998,7 +989,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1013,7 +1004,7 @@ "id": "native-chat-page-earlier.method-not-found:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -1025,7 +1016,7 @@ "id": "native-chat-page-earlier.method-not-found:paging", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1038,7 +1029,7 @@ "id": "native-chat-page-earlier.method-not-found:paged", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -1051,7 +1042,7 @@ "id": "native-chat-page-earlier.method-not-found:re-subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb"], + "payloads": ["456781b97acc", "2a5d879ed94b"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1065,7 +1056,7 @@ "id": "native-chat-page-earlier.method-not-found:replayed", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb"], + "payloads": ["456781b97acc", "2a5d879ed94b"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -1079,7 +1070,7 @@ "id": "native-chat-page-earlier.method-not-found:unmounted", "observation": { "sender": [], - "payloads": ["09f715780ff1", "db2678a902bb", "53eaf803beb5"], + "payloads": ["456781b97acc", "2a5d879ed94b", "a2b8cf4aa6d5"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index 9b429d1aec6..516ef620bf7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-page", "family": "session.native-chat-page", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", - "scenarioSha256": "f7535862ae280e7e47916ad25701040e25d3318baceea52515c9d81b4144ae92", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08ef53d83e8c": { "name": "nativeChat.readSession#1", @@ -31,16 +22,6 @@ "status": "ready", "transcriptLoading": false }, - "09f715780ff1": { - "name": "nativeChat.subscribe#1", - "ordinal": 1, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" - }, - "1c6ad14c561f": { - "name": "nativeChat.unsubscribe#1", - "ordinal": 5, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, "2614fb8bb258": { "name": "stream-listener-crash", "ordinal": 6, @@ -53,15 +34,15 @@ "frame": "nativeChat.subscribe#2" } }, - "3fb11bd56adb": { + "3e14b1ec5bde": { "name": "nativeChat.unsubscribe#2", - "ordinal": 7, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" + "ordinal": 6, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" }, - "459bc33ee698": { - "name": "nativeChat.subscribe#2", - "ordinal": 4, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + "456781b97acc": { + "name": "nativeChat.subscribe#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, "6252ebf3de79": { "name": "stream-listener-crash", @@ -75,6 +56,11 @@ "frame": "nativeChat.subscribe#2" } }, + "663babae1f1d": { + "name": "nativeChat.subscribe#2", + "ordinal": 4, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + }, "7b237e9824c8": { "crash": { "$rpc": "null" @@ -101,6 +87,11 @@ "status": "ready", "transcriptLoading": false }, + "7eedfcf76fd3": { + "name": "nativeChat.unsubscribe#1", + "ordinal": 5, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, "8f91342d7840": { "crash": { "$rpc": "null" @@ -242,10 +233,10 @@ "startedAt": 0 } }, - "e488a88a0a50": { + "e4483b4ab66e": { "name": "nativeChat.unsubscribe#2", - "ordinal": 6, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" + "ordinal": 7, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -276,7 +267,7 @@ "id": "native-chat-page-earlier.prelude:subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -288,7 +279,7 @@ "id": "native-chat-page-earlier.prelude:snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -300,7 +291,7 @@ "id": "native-chat-page-earlier.prelude:paging", "observation": { "sender": ["dadae0a56731"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -313,7 +304,7 @@ "id": "native-chat-page-earlier.prelude:paged", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -326,7 +317,7 @@ "id": "native-chat-page-earlier.prelude:re-subscribed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -340,7 +331,7 @@ "id": "native-chat-page-earlier.normal:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -355,11 +346,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -375,7 +366,7 @@ "id": "native-chat-page-earlier.result-absent:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -390,11 +381,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "3fb11bd56adb" + "663babae1f1d", + "7eedfcf76fd3", + "e4483b4ab66e" ], "settlements": { "mount": "eb79a9b3682a", @@ -410,7 +401,7 @@ "id": "native-chat-page-earlier.result-null:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -425,11 +416,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "3fb11bd56adb" + "663babae1f1d", + "7eedfcf76fd3", + "e4483b4ab66e" ], "settlements": { "mount": "eb79a9b3682a", @@ -445,7 +436,7 @@ "id": "native-chat-page-earlier.inner-ok-missing:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -460,11 +451,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -480,7 +471,7 @@ "id": "native-chat-page-earlier.inner-false-string-error:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -495,11 +486,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -515,7 +506,7 @@ "id": "native-chat-page-earlier.inner-false-object-error:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -530,11 +521,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", @@ -550,7 +541,7 @@ "id": "native-chat-page-earlier.outer-refused:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -564,7 +555,7 @@ "id": "native-chat-page-earlier.outer-refused:unmounted", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -579,7 +570,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -593,7 +584,7 @@ "id": "native-chat-page-earlier.outer-refused-no-message:unmounted", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -608,7 +599,7 @@ "id": "native-chat-page-earlier.method-not-found:replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -622,7 +613,7 @@ "id": "native-chat-page-earlier.method-not-found:unmounted", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json index 88889306408..fe03d3a9d25 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-readability", "family": "session.native-chat-readability", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "d6a0472f274d55aab584b58b67018d042ee1ba6799f42072a8a5986f45554bfc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0b8777edb86c": { "readable": false, diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index ec3e90d6492..ecab8becfb2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-stop", "family": "session.native-chat-stop", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "a60de7b496f8149593a6e89cd2d29e20fdf75d26536592fec6b0100b43322d3b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "23018091278a": { "name": "orchestration.workerTerminalUserInput#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index 7c71d3750a3..9008a996265 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-stop", "family": "session.native-chat-stop", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "96499f352af2ff884bfa94996659609fdbd228e1d071ad462400b978ab8e239b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08f063891b6e": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index efed2de7e0b..64ae9b3e45e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-stop", "family": "session.native-chat-stop", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "c28251d769ca9788952ed4fb29d8665c870fb85f2c5a4f32f8af33baf44498af", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "042114cacf6b": { "name": "terminal.send#2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json index 4578ecc3ad3..589a6e1e467 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-branch-context", "family": "session.pr-branch-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "af78542ad2c449b629f8705b940ec92fd16f879a9bcc81ff6ae3a192f804fa2c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fc11d9bf2a5": { "name": "worktree.show#1", @@ -391,57 +382,6 @@ } } }, - "926edf8b660b": { - "name": "git.branchCompare#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "9270aeb7d9c6": { "status": "pending", "startedAt": 0 @@ -515,6 +455,57 @@ } } }, + "c0f7fd87f4a5": { + "name": "git.branchCompare#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "d0e28a73dae6": { "name": "git.status#1", "ordinal": 1, @@ -760,7 +751,7 @@ { "id": "pr-branch-identity.normal:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json index 801140db727..b92a0b44b1c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-branch-context", "family": "session.pr-branch-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "1e7cad00f4dfcda65a3830b0b2468020816b940611af1b68600de33cd8c1d7c2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bd4c9fb1332": { "name": "git.status#1", @@ -286,6 +277,18 @@ "startedAt": 0 } }, + "86e1c3d2fb6b": { + "identity": { + "branch": { + "$rpc": "null" + }, + "headSha": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "status": { + "$rpc": "null" + } + }, + "repoContext": "unread" + }, "8baf252a9a66": { "name": "git.status#1", "ordinal": 1, @@ -317,57 +320,6 @@ } } }, - "926edf8b660b": { - "name": "git.branchCompare#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/main", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 1, - "path": "src/old.ts", - "removed": 0, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "9270aeb7d9c6": { "status": "pending", "startedAt": 0 @@ -417,6 +369,57 @@ } } }, + "c0f7fd87f4a5": { + "name": "git.branchCompare#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/main", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 1, + "path": "src/old.ts", + "removed": 0, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "c33e0a3d4a1f": { "name": "git.status#1", "ordinal": 1, @@ -451,6 +454,20 @@ } } }, + "c4a22b2795dd": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "branch": { + "$rpc": "null" + }, + "headSha": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "status": { + "$rpc": "null" + } + } + }, "c7584e82c72f": { "status": "rejected", "startedAt": 0, @@ -510,20 +527,6 @@ } } }, - "d344a3126471": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "branch": { - "$rpc": "null" - }, - "headSha": "head-oid", - "status": { - "$rpc": "null" - } - } - }, "d45add7a8589": { "name": "git.status#1", "ordinal": 4, @@ -600,18 +603,6 @@ } } }, - "f043e677bc3b": { - "identity": { - "branch": { - "$rpc": "null" - }, - "headSha": "head-oid", - "status": { - "$rpc": "null" - } - }, - "repoContext": "unread" - }, "f0e28a4b20aa": { "identity": { "branch": "feature", @@ -796,7 +787,7 @@ { "id": "pr-branch-identity.normal:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -808,103 +799,103 @@ { "id": "pr-branch-identity.result-absent:identity", "observation": { - "sender": ["8baf252a9a66", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["8baf252a9a66", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.result-null:identity", "observation": { - "sender": ["c33e0a3d4a1f", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["c33e0a3d4a1f", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.inner-ok-missing:identity", "observation": { - "sender": ["ebfcf8909ca0", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["ebfcf8909ca0", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.inner-false-string-error:identity", "observation": { - "sender": ["5a46fcc976ac", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["5a46fcc976ac", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.inner-false-object-error:identity", "observation": { - "sender": ["fb05490ae527", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["fb05490ae527", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.outer-refused:identity", "observation": { - "sender": ["b2181825a3ca", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["b2181825a3ca", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.outer-refused-no-message:identity", "observation": { - "sender": ["0bd4c9fb1332", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["0bd4c9fb1332", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.method-not-found:identity", "observation": { - "sender": ["45d1ea7c0b42", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["45d1ea7c0b42", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { - "identity": "d344a3126471" + "identity": "c4a22b2795dd" }, - "state": "f043e677bc3b", + "state": "86e1c3d2fb6b", "effects": [] } }, { "id": "pr-branch-identity.transport-rejection:identity", "observation": { - "sender": ["eb92786433d3", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["eb92786433d3", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "a947768bc0ed" @@ -916,7 +907,7 @@ { "id": "pr-branch-identity.transport-rejection-no-message:identity", "observation": { - "sender": ["fa4ee3a35705", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["fa4ee3a35705", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "c7584e82c72f" diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json index 24a2b9b0d4c..cdf5efa17c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-branch-context", "family": "session.pr-branch-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "d456ec18056eb9663e99d4991784bd802422256d81ddd7509720814da06915f4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03628457e494": { "name": "repo.list#1", @@ -451,7 +442,46 @@ "startedAt": 0 } }, - "926edf8b660b": { + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "b7d1d887631a": { + "name": "repo.list#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "repo.list" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "c0f7fd87f4a5": { "name": "git.branchCompare#1", "ordinal": 7, "args": [ @@ -490,57 +520,18 @@ } ], "summary": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", "changedFiles": 1, "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "status": "ready" } } } } }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "b7d1d887631a": { - "name": "repo.list#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, "d0e28a73dae6": { "name": "git.status#1", "ordinal": 1, @@ -750,7 +741,7 @@ { "id": "pr-branch-identity.normal:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -762,7 +753,7 @@ { "id": "pr-branch-identity.result-absent:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "61632cddf4b9", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "61632cddf4b9", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -774,7 +765,7 @@ { "id": "pr-branch-identity.result-null:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "35ed1ca1f704", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "35ed1ca1f704", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -786,7 +777,7 @@ { "id": "pr-branch-identity.inner-ok-missing:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "22bd3b4b3cd0", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "22bd3b4b3cd0", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -798,7 +789,7 @@ { "id": "pr-branch-identity.inner-false-string-error:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "b7d1d887631a", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "b7d1d887631a", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -810,7 +801,7 @@ { "id": "pr-branch-identity.inner-false-object-error:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "16f3b408254b", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "16f3b408254b", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -822,7 +813,7 @@ { "id": "pr-branch-identity.outer-refused:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "d5c9abd94695", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "d5c9abd94695", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -834,7 +825,7 @@ { "id": "pr-branch-identity.outer-refused-no-message:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "235244774153", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "235244774153", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -846,7 +837,7 @@ { "id": "pr-branch-identity.method-not-found:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "4bacf9ca69b3", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "4bacf9ca69b3", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -858,7 +849,7 @@ { "id": "pr-branch-identity.transport-rejection:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "4a8214d9a3ed", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "4a8214d9a3ed", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -870,7 +861,7 @@ { "id": "pr-branch-identity.transport-rejection-no-message:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "03628457e494", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "03628457e494", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json index 7c9de5929c3..30939363d1d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-branch-context", "family": "session.pr-branch-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "039f26cc90d2239028d6ad1d9ecae9cc976d48f386f2d29fbfa425afc702657d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03dabf7904c5": { "name": "worktree.show#1", @@ -380,7 +371,46 @@ } } }, - "926edf8b660b": { + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "ade44fd82eca": { + "name": "worktree.show#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "worktree.show" + }, + { + "name": "params", + "value": { + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "c0f7fd87f4a5": { "name": "git.branchCompare#1", "ordinal": 7, "args": [ @@ -419,57 +449,18 @@ } ], "summary": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", "changedFiles": 1, "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "status": "ready" } } } } }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "ade44fd82eca": { - "name": "worktree.show#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "worktree.show" - }, - { - "name": "params", - "value": { - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, "d0e28a73dae6": { "name": "git.status#1", "ordinal": 1, @@ -750,7 +741,7 @@ { "id": "pr-branch-identity.normal:identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -762,7 +753,7 @@ { "id": "pr-branch-identity.result-absent:identity", "observation": { - "sender": ["d0e28a73dae6", "7eb2a61711fc", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "7eb2a61711fc", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -774,7 +765,7 @@ { "id": "pr-branch-identity.result-null:identity", "observation": { - "sender": ["d0e28a73dae6", "552f85dcd6b4", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "552f85dcd6b4", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -786,7 +777,7 @@ { "id": "pr-branch-identity.inner-ok-missing:identity", "observation": { - "sender": ["d0e28a73dae6", "f6b2f58dcf76", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "f6b2f58dcf76", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -798,7 +789,7 @@ { "id": "pr-branch-identity.inner-false-string-error:identity", "observation": { - "sender": ["d0e28a73dae6", "ade44fd82eca", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "ade44fd82eca", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -810,7 +801,7 @@ { "id": "pr-branch-identity.inner-false-object-error:identity", "observation": { - "sender": ["d0e28a73dae6", "f8ad59a07e10", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "f8ad59a07e10", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -822,7 +813,7 @@ { "id": "pr-branch-identity.outer-refused:identity", "observation": { - "sender": ["d0e28a73dae6", "f14112e24d21", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "f14112e24d21", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -834,7 +825,7 @@ { "id": "pr-branch-identity.outer-refused-no-message:identity", "observation": { - "sender": ["d0e28a73dae6", "5087d259003b", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "5087d259003b", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -846,7 +837,7 @@ { "id": "pr-branch-identity.method-not-found:identity", "observation": { - "sender": ["d0e28a73dae6", "1a1f37b92aca", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "1a1f37b92aca", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -858,7 +849,7 @@ { "id": "pr-branch-identity.transport-rejection:identity", "observation": { - "sender": ["d0e28a73dae6", "03dabf7904c5", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "03dabf7904c5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" @@ -870,7 +861,7 @@ { "id": "pr-branch-identity.transport-rejection-no-message:identity", "observation": { - "sender": ["d0e28a73dae6", "3fdee65f324d", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "3fdee65f324d", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json index 5537e82363f..b745ffdcb85 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-sidebar", "family": "session.pr-sidebar", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", - "scenarioSha256": "97a92fc3e9b496a6295fb01e490407c1154cd19c54777fdce67377e07af08729", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "07639c4f1e7f": { "name": "hostedReview.forBranch#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json index 7102357f0fe..9a9a104f58b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-sidebar", "family": "session.pr-sidebar", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", - "scenarioSha256": "cb052d2107df7bbf24927199935dde7f30352ce875c7ddd7bbab7d8044da0add", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "07639c4f1e7f": { "name": "hostedReview.forBranch#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json index c05867fa10f..84bbde5a742 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-sidebar", "family": "session.pr-sidebar", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", - "scenarioSha256": "461af8bf31bbf47e84f64a9177bae34c9b796a3f0158a16041ccb0a1a3ea62ec", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "05ddd233b0e4": { "name": "hostedReview.forBranch#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json index edb26846e0d..e247bab1f95 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-sidebar", "family": "session.pr-sidebar", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", - "scenarioSha256": "1bde693e45b01d09c47a1542cb7e43b4b7798ae52755ba41c5812bc07a425914", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03dabf7904c5": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json index 7cb300eb411..1a8889872c5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "ce91b60bc94d61ac67714621cb88c1db62b51f1a6638325b985472eadfcca16b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json index fcb3330b818..7f3f5f9c03a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "53a83cb160e90a8be808d9f0bb31826e355d43dd1e42fa6cccf7ca5398b8b579", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json index d3f6bbcbd6a..209476fc280 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "c9030f183de2c5e86c2ccdfd9d4527d6f2ed71855501ff061620cef2681c6b82", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01fc75db313f": { "launched": { diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json index 793cb1435cb..53f9961d97c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "39923c0faa941865cc9aead881fef91ce5db117120033fe73665356a61a2ecc6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json index 32770529f33..008843e3bc9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json @@ -1,19 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.review-branch-diff", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "05d42780508d46d5c88f0c987448dc693bb49208a4298e6cf2f0d5e3979066af", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "184898f3448c": { + "0e9f3cc7c92d": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -25,10 +16,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -47,10 +38,88 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" + "ok": true + } + } + }, + "1f67ebbc6508": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo-9::/w" } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "20736f2985e3": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, @@ -83,7 +152,7 @@ "isRpcDeliveryUnknown": false } }, - "47bfba92ccde": { + "39385954dc3e": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -95,104 +164,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-1", - "ok": false - } - } - }, - "4a1c896f4fef": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "534cf7badcbc": { - "branchCompare": "unloaded", - "diff": { - "itemKey": "branch:src/app.ts", - "kind": "binary" - }, - "snapshot": "unloaded" - }, - "551c785629a1": { - "name": "git.branchDiff#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"base-oid\",\"headOid\":\"head-oid\",\"mergeBase\":\"merge-base\"}}}" - }, - "638cb6ada56b": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -218,6 +193,14 @@ } } }, + "534cf7badcbc": { + "branchCompare": "unloaded", + "diff": { + "itemKey": "branch:src/app.ts", + "kind": "binary" + }, + "snapshot": "unloaded" + }, "67daf7391fee": { "status": "rejected", "startedAt": 0, @@ -228,7 +211,7 @@ "isRpcDeliveryUnknown": false } }, - "714ef7b515d2": { + "6b2377e6a206": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -240,10 +223,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -261,15 +244,16 @@ "startedAt": 0, "settledAt": 0, "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, "id": "frame-1", - "ok": true, - "result": { - "kind": "binary" - } + "ok": false } } }, - "895406b61707": { + "8d7cfb52bd80": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -281,10 +265,82 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "ac85149368b6": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "The host sent a reply this app could not read (git.branchDiff)", + "isRpcDeliveryUnknown": false + } + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c01a2afcee3a": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -313,7 +369,7 @@ } } }, - "9234de2a5458": { + "c483883c0ca6": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -325,111 +381,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "a947768bc0ed": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - }, - "ac85149368b6": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "The host sent a reply this app could not read (git.branchDiff)", - "isRpcDeliveryUnknown": false - } - }, - "b0f6c8c624f6": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" - }, - "filePath": "src/app.ts", - "worktree": "id:repo-9::/w" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } - }, - "b440c4acb647": { - "name": "git.branchDiff#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "git.branchDiff" - }, - { - "name": "params", - "value": { - "compare": { - "baseOid": "base-oid", - "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -448,20 +403,13 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true + "ok": true, + "result": { + "$rpc": "null" + } } } }, - "b948e8307e81": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Unknown method", - "isRpcDeliveryUnknown": false - } - }, "c7584e82c72f": { "status": "rejected", "startedAt": 0, @@ -472,7 +420,12 @@ "isRpcDeliveryUnknown": true } }, - "c7d45ceefb2c": { + "ce804facf021": { + "name": "git.branchDiff#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\",\"headOid\":\"f0e1d2c3b4a5968778695a4b3c2d1e0f98765432\",\"mergeBase\":\"0123456789abcdef0123456789abcdef01234567\"}}}" + }, + "d24ea9d9187c": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -484,10 +437,56 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" + }, + "filePath": "src/app.ts", + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "kind": "binary" + } + } + } + }, + "e39817462870": { + "branchCompare": "unloaded", + "diff": "unloaded", + "snapshot": "unloaded" + }, + "fa8afc93c0b2": { + "name": "git.branchDiff#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "git.branchDiff" + }, + { + "name": "params", + "value": { + "compare": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/main", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -514,12 +513,7 @@ } } }, - "e39817462870": { - "branchCompare": "unloaded", - "diff": "unloaded", - "snapshot": "unloaded" - }, - "ed7ba902b54c": { + "fc9ce136fe13": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -531,10 +525,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -548,16 +542,13 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true } } } @@ -568,8 +559,8 @@ { "id": "review-branch-diff-shapes.normal:branch", "observation": { - "sender": ["714ef7b515d2"], - "payloads": ["551c785629a1"], + "sender": ["d24ea9d9187c"], + "payloads": ["ce804facf021"], "settlements": { "branch": "365c17523d76" }, @@ -580,8 +571,8 @@ { "id": "review-branch-diff-shapes.normal:no-compare", "observation": { - "sender": ["714ef7b515d2"], - "payloads": ["551c785629a1"], + "sender": ["d24ea9d9187c"], + "payloads": ["ce804facf021"], "settlements": { "branch": "365c17523d76", "no-compare": "38fb361f406c" @@ -593,8 +584,8 @@ { "id": "review-branch-diff-shapes.result-absent:branch", "observation": { - "sender": ["b440c4acb647"], - "payloads": ["551c785629a1"], + "sender": ["0e9f3cc7c92d"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6" }, @@ -605,8 +596,8 @@ { "id": "review-branch-diff-shapes.result-absent:no-compare", "observation": { - "sender": ["b440c4acb647"], - "payloads": ["551c785629a1"], + "sender": ["0e9f3cc7c92d"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6", "no-compare": "38fb361f406c" @@ -618,8 +609,8 @@ { "id": "review-branch-diff-shapes.result-null:branch", "observation": { - "sender": ["184898f3448c"], - "payloads": ["551c785629a1"], + "sender": ["c483883c0ca6"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6" }, @@ -630,8 +621,8 @@ { "id": "review-branch-diff-shapes.result-null:no-compare", "observation": { - "sender": ["184898f3448c"], - "payloads": ["551c785629a1"], + "sender": ["c483883c0ca6"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6", "no-compare": "38fb361f406c" @@ -643,8 +634,8 @@ { "id": "review-branch-diff-shapes.inner-ok-missing:branch", "observation": { - "sender": ["638cb6ada56b"], - "payloads": ["551c785629a1"], + "sender": ["39385954dc3e"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6" }, @@ -655,8 +646,8 @@ { "id": "review-branch-diff-shapes.inner-ok-missing:no-compare", "observation": { - "sender": ["638cb6ada56b"], - "payloads": ["551c785629a1"], + "sender": ["39385954dc3e"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6", "no-compare": "38fb361f406c" @@ -668,8 +659,8 @@ { "id": "review-branch-diff-shapes.inner-false-string-error:branch", "observation": { - "sender": ["c7d45ceefb2c"], - "payloads": ["551c785629a1"], + "sender": ["fa8afc93c0b2"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6" }, @@ -680,8 +671,8 @@ { "id": "review-branch-diff-shapes.inner-false-string-error:no-compare", "observation": { - "sender": ["c7d45ceefb2c"], - "payloads": ["551c785629a1"], + "sender": ["fa8afc93c0b2"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6", "no-compare": "38fb361f406c" @@ -693,8 +684,8 @@ { "id": "review-branch-diff-shapes.inner-false-object-error:branch", "observation": { - "sender": ["895406b61707"], - "payloads": ["551c785629a1"], + "sender": ["c01a2afcee3a"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6" }, @@ -705,8 +696,8 @@ { "id": "review-branch-diff-shapes.inner-false-object-error:no-compare", "observation": { - "sender": ["895406b61707"], - "payloads": ["551c785629a1"], + "sender": ["c01a2afcee3a"], + "payloads": ["ce804facf021"], "settlements": { "branch": "ac85149368b6", "no-compare": "38fb361f406c" @@ -718,8 +709,8 @@ { "id": "review-branch-diff-shapes.outer-refused:branch", "observation": { - "sender": ["ed7ba902b54c"], - "payloads": ["551c785629a1"], + "sender": ["6b2377e6a206"], + "payloads": ["ce804facf021"], "settlements": { "branch": "32a7c0ae7918" }, @@ -730,8 +721,8 @@ { "id": "review-branch-diff-shapes.outer-refused:no-compare", "observation": { - "sender": ["ed7ba902b54c"], - "payloads": ["551c785629a1"], + "sender": ["6b2377e6a206"], + "payloads": ["ce804facf021"], "settlements": { "branch": "32a7c0ae7918", "no-compare": "38fb361f406c" @@ -743,8 +734,8 @@ { "id": "review-branch-diff-shapes.outer-refused-no-message:branch", "observation": { - "sender": ["47bfba92ccde"], - "payloads": ["551c785629a1"], + "sender": ["1f67ebbc6508"], + "payloads": ["ce804facf021"], "settlements": { "branch": "67daf7391fee" }, @@ -755,8 +746,8 @@ { "id": "review-branch-diff-shapes.outer-refused-no-message:no-compare", "observation": { - "sender": ["47bfba92ccde"], - "payloads": ["551c785629a1"], + "sender": ["1f67ebbc6508"], + "payloads": ["ce804facf021"], "settlements": { "branch": "67daf7391fee", "no-compare": "38fb361f406c" @@ -768,8 +759,8 @@ { "id": "review-branch-diff-shapes.method-not-found:branch", "observation": { - "sender": ["b0f6c8c624f6"], - "payloads": ["551c785629a1"], + "sender": ["8d7cfb52bd80"], + "payloads": ["ce804facf021"], "settlements": { "branch": "b948e8307e81" }, @@ -780,8 +771,8 @@ { "id": "review-branch-diff-shapes.method-not-found:no-compare", "observation": { - "sender": ["b0f6c8c624f6"], - "payloads": ["551c785629a1"], + "sender": ["8d7cfb52bd80"], + "payloads": ["ce804facf021"], "settlements": { "branch": "b948e8307e81", "no-compare": "38fb361f406c" @@ -793,8 +784,8 @@ { "id": "review-branch-diff-shapes.transport-rejection:branch", "observation": { - "sender": ["4a1c896f4fef"], - "payloads": ["551c785629a1"], + "sender": ["20736f2985e3"], + "payloads": ["ce804facf021"], "settlements": { "branch": "a947768bc0ed" }, @@ -805,8 +796,8 @@ { "id": "review-branch-diff-shapes.transport-rejection:no-compare", "observation": { - "sender": ["4a1c896f4fef"], - "payloads": ["551c785629a1"], + "sender": ["20736f2985e3"], + "payloads": ["ce804facf021"], "settlements": { "branch": "a947768bc0ed", "no-compare": "38fb361f406c" @@ -818,8 +809,8 @@ { "id": "review-branch-diff-shapes.transport-rejection-no-message:branch", "observation": { - "sender": ["9234de2a5458"], - "payloads": ["551c785629a1"], + "sender": ["fc9ce136fe13"], + "payloads": ["ce804facf021"], "settlements": { "branch": "c7584e82c72f" }, @@ -830,8 +821,8 @@ { "id": "review-branch-diff-shapes.transport-rejection-no-message:no-compare", "observation": { - "sender": ["9234de2a5458"], - "payloads": ["551c785629a1"], + "sender": ["fc9ce136fe13"], + "payloads": ["ce804facf021"], "settlements": { "branch": "c7584e82c72f", "no-compare": "38fb361f406c" diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json index 79dcf7b791f..989f0340f51 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.review-file-diff", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "9567f8594e0287954c1b236b2f0617ed06be04d2b97f82e527c8e3ce732e65c1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "168eeea61142": { "name": "git.diff#3", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json index a15faee5c95..e58181d44f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.review-file-diff", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "95c8c5451aabbbebab8422ccd867b70e000ac62aacf1d231ad0d181cbec3a2b0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "168eeea61142": { "name": "git.diff#3", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json index 90b7d342164..3823421e604 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.review-file-diff", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "91558fbfdf10c17a363e38cc0489b1a59e8b1657c51f7444840b2e7d37646851", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "168eeea61142": { "name": "git.diff#3", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index add506aecc9..3d002317241 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.review-git-mutations", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "651cb070a4b4ba7deddeb37c08886094b83f006e7cbe2bef65d5433ce0430dff", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1707a69cd9b6": { "name": "git.discard#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index d79b2df09c7..df45c274d18 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.review-git-mutations", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "e0ce643010302d304ba3611bd43932c6cdd0fa9290a3ecd4fffb4fc92c758b77", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13e2ebbc75ce": { "name": "git.stage#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index affdd0974f5..02c80ab79a1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.review-git-mutations", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "c6b3b4be6b55daf53ee1f5ff755e73a032c4da5379f90f4973736c032eb79414", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "186448e4f2b2": { "name": "git.stage#2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index 8e429ea6b72..f5990fc0488 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.review-send-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "a71815661dee6129a1b133c398b37ea89a9e4939b45626dada16889acabb2afe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1cfc4f5c9474": { "name": "session.tabs.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json index e6122841e48..c6de547926f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.startup", "family": "session.startup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", - "scenarioSha256": "f4d1e8eff8e1a3dc7b04fcf5dafb2befca2211922443f1958ba801c49f488152", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0439d4e9a9bf": { "name": "fetch-terminals", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json index 4e2a55ecc11..4dd5c96005d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.startup", "family": "session.startup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", - "scenarioSha256": "7ba969778e57cc5591365c688ef13ce5b09c41db9fa3666d61bbbbfe4e3fd35c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0225f2b6107b": { "name": "clear-pending-restorations", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json index 7c127588951..5b06617b5e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-activation", "family": "session.tab-activation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "d25fc19d4e3e9b12f2a60a076ea10741e13fc45dcd9a76bd3a9d88432c3e6fbe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1ddcf41b827c": { "name": "session.tabs.activate#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json index 8c00e200a5b..40c190c4653 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-activation", "family": "session.tab-activation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "5b050af6f02aa90f66340838cb5cc53c8fc0d5693d313b653c23881150384874", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0590b758ddc1": { "activate": { diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json index 6cfee9aa3e2..1162226aa20 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close-session", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "64c12ae20aaf7202c2271134d57e4312127e8f909ad086ba69b4662bbd749bb0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bf439aa2905": { "name": "session.tabs.close#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json index 77844549a7c..eae8162f93e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "89141e3e400feea78460ede72d5269bdc885f6d3de75388542b4b7d6dff2840d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "021e509dd685": { "name": "terminal.close#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json index 80c376c3ea6..47da84b8a38 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.tab-documents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "c97d925b63253e87ada0777e95a24ccf4e4e1682eda048800b44e335767df648", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11d4233470ab": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json index 64a564d7fa5..58c417257b5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-rename", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "55c461287c1d8fb6de3c4fe28f801d095678dcf52bfcce39c336c9d61e8be908", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "056772c9ebd6": { "name": "terminal.rename#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index c6bb3eca2ee..e784a771e3f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.session-diff-reveal", "family": "session.tab-reveal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "73366226aeaec1581aeeb47219fc703917143cfd7f6a2eb01d7bd703a7c7612d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bc55a97ecd6": { "name": "session.tabs.activate#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index be97568a3b3..b4a6a36fa3b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.session-diff-reveal", "family": "session.tab-reveal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "c38f2bc5c9faca0774dfe202137877bada9deba165c5e9c955cbe67eae0cbdd9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1da2ff4ec6f6": { "name": "session.tabs.activate#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json index 3d0e30b909e..5d41837bc4d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tabs-stream-health", "family": "session.tabs-stream-health", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "fab12524a09049d976da752cbe1b837a0aee04ce6e1eef75cbf517c862cb0d4a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01a7e0d83191": { "name": "session.tabs.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json index cf8cb3ea08b..3dd3c432ab8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-display-mode", "family": "session.terminal-display-mode", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", - "scenarioSha256": "2ae1839ae5f4fb98c2ef250fb5e071508ec1376e737dab28ac589c08f1a42b0e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0adddb41fd5b": { "name": "terminal.setDisplayMode#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json index a91abecbfec..8c3c0c69e15 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-gesture-input", "family": "session.terminal-gesture-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", - "scenarioSha256": "27e0d44ca22593ff2ecef93de075508863024f3f10f00161818962c07e7b59e1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bbc8e9d2e60": { "name": "orchestration.workerTerminalUserInput#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json index 9a73b8cb753..b987894bdd0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-gesture-input", "family": "session.terminal-gesture-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", - "scenarioSha256": "53ffb52ef015193fdaaeb1ac5a1c88488a607e6cec2e39e87d1e4e3173321983", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0cc3e25ebff5": { "bucketTokens": 63, diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json index 5e7fe880f0c..6c22a77635d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-gesture-input", "family": "session.terminal-gesture-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", - "scenarioSha256": "d7bc4d7a3e7e54accd4b51eef7e83ae70cc0c794738a83af222503a0f12ba70f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "070ef6f33862": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json index f50d92b6500..84616d42b80 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-input-send", "family": "session.terminal-input-send", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "cde3cac5407ef731315d18fc855b2696b9bfd30b90cb43d4a2cc812059cdd987", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ba725456703": { "crash": { diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json index b1214f83f76..ace7064a4f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-input-send", "family": "session.terminal-input-send", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "6575a0f89894d9b66e50a73446dfe92293ceccc7048b556f1ff114fd3ce05b8e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0999c63e5261": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json index 9d938aafb56..eecfd819c34 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-inventory", "family": "session.terminal-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "12f1006d704e362552317a3afcb4db4366146da3a863a1c55b524c6fc1b9757a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "25453c36879e": { "name": "terminal.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json index 7c10f98cf15..a8eb6885450 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.terminal-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "27c4f13ae43042cf5e6b5ca95e9c1a37db2f1f0c08b31a1164bb56cf0967549a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1367658ca985": { "name": "orchestration.workerTerminalUserInput#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json index fd21bdcd9e3..306fbafa3e2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.terminal-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "449f8c793a371dddf1bb9b3beb36b2dbd9b991918dae1f6290df75fe6d2aeace", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "26d0fe750677": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json index 1f4bcaf11fa..0d61ae48a5b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.terminal-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "5cc67ab6df80a81be814a16cb60dcc4c703b0fc17594e409697a9fdeb0edeb76", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "26d0fe750677": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json index 2b9f88e9767..5acfc3e304f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.worktree-connection", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "e7fb7a8083aac4c8c5edc7dd52465ca53bfcded00bf8b1ec18ae7604651bbe32", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03628457e494": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json index 5fc76824eb1..72c9f77f6d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.worktree-connection", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "b1e1b221ab7bfe5356c89a09cf4252613c78aecab48b2212e84ac7de885ec569", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2b502b73f842": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index b1a05557a62..8c69aba0616 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "a0effc9a0be519ccd18c1b1abfc8b497cd3858b89ea8d345ac0f8bd6d195cf21", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index aed8cd2f466..f13d660ee13 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "c21b2e0e97fab86664f634cc99d77dd587df4af4d02e6286c8380e09844096b2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index 58eaf3c62c8..4c47c189c57 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "46c4e32a921612c736c8cf45ff72ed513431c917ed3dd03f289c0ba4c28d6adb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index 019b7bf7860..a6bf10b6829 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -1,19 +1,15 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-preferences", "family": "settings-best-effort", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "cf671da175d50a4c2e1336f4e8338c24c4752db111e1eafd226bee6ff3582b1d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "20067abe0cdb": { + "03d1fef54a8c": { + "name": "settings.update#1", + "ordinal": 3, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.update\",\"params\":{\"defaultTaskViewPreset\":\"my-issues\"}}" + }, + "0e0e7cfa77f2": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -24,7 +20,76 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "0e6a18c4b259": { + "name": "settings.update#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.update" + }, + { + "name": "params", + "value": { + "defaultTaskViewPreset": "my-issues" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "ok": true + } + } + } + }, + "38f60ac30222": { + "preset": "my-issues" + }, + "50cc7bfddb0c": { + "name": "settings.update#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.update" + }, + { + "name": "params", + "value": { + "defaultTaskViewPreset": "my-issues" } }, { @@ -48,12 +113,12 @@ } } }, - "5957e7c52ab6": { - "name": "settings.update#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.update\",\"params\":{\"defaultTaskViewPreset\":\"assigned\"}}" + "a10aec8208b3": { + "name": "defaultGitHubPreset", + "ordinal": 1, + "value": "my-issues" }, - "5a2a900f746d": { + "a2be01c53c71": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -64,7 +129,7 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" } }, { @@ -80,11 +145,14 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true + "ok": true, + "result": { + "error": "refused" + } } } }, - "5a40ae118c80": { + "a4380be3778f": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -95,7 +163,7 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" } }, { @@ -106,20 +174,20 @@ } ], "settlement": { - "status": "rejected", + "status": "fulfilled", "startedAt": 0, "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } } } }, - "5db909d58c6f": { - "preset": "assigned" - }, - "66d0a96740cd": { + "ad6e66b54916": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -130,7 +198,41 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "af739459a0c3": { + "name": "settings.update#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.update" + }, + { + "name": "params", + "value": { + "defaultTaskViewPreset": "my-issues" } }, { @@ -156,7 +258,7 @@ } } }, - "773b3127c2df": { + "b07b2522267c": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -167,172 +269,7 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "77db919d5a63": { - "name": "settings.update#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.update" - }, - { - "name": "params", - "value": { - "defaultTaskViewPreset": "assigned" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "7a3df9a9d533": { - "name": "settings.update#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.update" - }, - { - "name": "params", - "value": { - "defaultTaskViewPreset": "assigned" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "7b47ae02f8c4": { - "name": "settings.update#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.update" - }, - { - "name": "params", - "value": { - "defaultTaskViewPreset": "assigned" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "ok": true - } - } - } - }, - "ad91b485db0c": { - "name": "defaultGitHubPreset", - "ordinal": 1, - "value": "assigned" - }, - "bbe37721bfbe": { - "name": "settings.update#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.update" - }, - { - "name": "params", - "value": { - "defaultTaskViewPreset": "assigned" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "c9f31916fca4": { - "name": "settings.update#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.update" - }, - { - "name": "params", - "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" } }, { @@ -356,7 +293,7 @@ } } }, - "cbf52c7a2082": { + "b1e59b9278fe": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -367,7 +304,96 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "b45fc49714f4": { + "name": "settings.update#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.update" + }, + { + "name": "params", + "value": { + "defaultTaskViewPreset": "my-issues" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "b6c9da6aafa0": { + "name": "settings.update#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.update" + }, + { + "name": "params", + "value": { + "defaultTaskViewPreset": "my-issues" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "c8357df6a135": { + "name": "settings.update#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.update" + }, + { + "name": "params", + "value": { + "defaultTaskViewPreset": "my-issues" } }, { @@ -391,41 +417,6 @@ } } }, - "e5883522e7a1": { - "name": "settings.update#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.update" - }, - { - "name": "params", - "value": { - "defaultTaskViewPreset": "assigned" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -441,157 +432,157 @@ { "id": "settings-task-write.prelude:optimistic", "observation": { - "sender": ["bbe37721bfbe"], - "payloads": ["5957e7c52ab6"], + "sender": ["b6c9da6aafa0"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.normal:settled", "observation": { - "sender": ["7b47ae02f8c4"], - "payloads": ["5957e7c52ab6"], + "sender": ["0e6a18c4b259"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.result-absent:settled", "observation": { - "sender": ["5a2a900f746d"], - "payloads": ["5957e7c52ab6"], + "sender": ["b1e59b9278fe"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.result-null:settled", "observation": { - "sender": ["7a3df9a9d533"], - "payloads": ["5957e7c52ab6"], + "sender": ["ad6e66b54916"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.inner-ok-missing:settled", "observation": { - "sender": ["773b3127c2df"], - "payloads": ["5957e7c52ab6"], + "sender": ["a2be01c53c71"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.inner-false-string-error:settled", "observation": { - "sender": ["e5883522e7a1"], - "payloads": ["5957e7c52ab6"], + "sender": ["a4380be3778f"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.inner-false-object-error:settled", "observation": { - "sender": ["66d0a96740cd"], - "payloads": ["5957e7c52ab6"], + "sender": ["af739459a0c3"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.outer-refused:settled", "observation": { - "sender": ["cbf52c7a2082"], - "payloads": ["5957e7c52ab6"], + "sender": ["c8357df6a135"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.outer-refused-no-message:settled", "observation": { - "sender": ["20067abe0cdb"], - "payloads": ["5957e7c52ab6"], + "sender": ["50cc7bfddb0c"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.method-not-found:settled", "observation": { - "sender": ["c9f31916fca4"], - "payloads": ["5957e7c52ab6"], + "sender": ["b07b2522267c"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.transport-rejection:settled", "observation": { - "sender": ["5a40ae118c80"], - "payloads": ["5957e7c52ab6"], + "sender": ["0e0e7cfa77f2"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settings-task-write.transport-rejection-no-message:settled", "observation": { - "sender": ["77db919d5a63"], - "payloads": ["5957e7c52ab6"], + "sender": ["b45fc49714f4"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } } ] diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index b29402a60a3..d070c0e92f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "01408cebcc193f8e30119381c8acf494fa5e29850fe010809deb330c2f9bcb36", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2b502b73f842": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index b513a25ec2e..28f1a580bc3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "19b445b39da98d28bcbcdab6f70e47ce208ca68f165e7b62c5fe9762eee67c8d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "015b0f5c6e12": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index 3357d1ad059..6e2c6479f25 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "4953dd0de509ce620b9840d7f460e472dc74f54d53636694d12cba2e3bb51da8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00a1ac4d876f": { "name": "preflight.check#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index 697bd7a56dc..5286956a1dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "70f601caeaee957bd3b172fc0fc12e85d6e2d6bed7683c86869559c6c9f25834", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08bc52c3b785": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index 5271ca6e711..2bd0e22edf7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings.new-tab-local-agents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "c5db2d14be8dc1b3b3b68c151507da49d1c96b44f94b080c5908a65a885f1763", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index 1fa58694ab9..6a827810397 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings.new-tab-local-agents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "c9b66eb3c676b1e18db588b8e35f267f5618c007ae0955adcc3292740fddfe23", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index 0413b5312f0..1c1b1fc507c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings.new-tab-local-agents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "9eaad87e8f648fdbfa33f493f991f9c7943fbf81f93580067819c4bbb02b672f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json index 781c4453fb3..5660f8298c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.quick-commands", "family": "settings.quick-commands", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "c6d5040d0fd1e6b852625561aa67d11f555f5adb12303b6f8d0176183026a6b1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "293828e985f4": { "name": "settings.getTerminalQuickCommands#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json index ed8b968d466..8e3565ab414 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.quick-commands", "family": "settings.quick-commands", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "b9edb5c27852d4e1e968f85668f1ea7afde3ed1c6e5c6582d6607f9fa5643356", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "011fc11cb355": { "name": "settings.updateTerminalQuickCommands#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 22f64f961aa..061899e316a 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "687b109bd2bcc0c85b7c858d553e68e2fc4cb5b281d9f8b32836dbacc4bdc8f2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index 850aad0c45b..4256c025d06 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "3199c745e22973b432b0a36c34bb0bdda994334a4a0cd7ad2daf8b172625ce8d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index 4cc0de8e3c1..4052935a5e6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "5e9c3ff57cf432b24a17ee046636b61b94116a687cfa506cd79dee464542b76b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index 210ae740e67..8ba0eb0ce8d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "a88061d3d1f03074b0ed2b663b523f1602362bc317ba614106f1f646d037d6e3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index 18f97a13d2b..786d8cbf6d1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "50ba7c7963cb494e4b3d484eb334977d21cc69018da57d75a7b6fd0c92860bd2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index 58aa1c29348..15a63190c9e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "8dfd4550b39f0cfcb9aaa72fab0631b11f9e776ed389b206b326359d7f4c2d6e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index f105cd29f96..3147e8b4eef 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "85dc526201f66409dd6a411c5e14615b82389791ec210efb9889078f5d580373", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index 4680068df63..e1b7161e9e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "080e10ae774ef097082267da0c8b6d0ebacae582d57b04a189c123258d0e5131", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index 4e83e3a031b..6ec6da13f24 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "5988fa5ce0bf6b8585f7ec66918123ee086d5cdf1185a4eeff2e88904985064c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index 4f1e509abb0..cbc723e1d4e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "f5033a7a3567cc9e016bf09ac8bcd8ff381c3054c041dbccc773f7011918bf1d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002332616f26": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index c268b5e77fd..773d3d66afe 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "0931b3d35868e5452cb550962f2408b6ce7cd6c89e90a9cf2897425edbb4b42d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index 981d619a412..779a9e4d7c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "d0f8d9bfe0e1469af3b0dab8b5c9799d91cc2234e72f0e031d6872059654077d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index 627a85f4dd2..f7d7b29cf5d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "3f448feff463b59c3927dae020ecd8d4931bb4a6036df6d6af080de2ec5fcf2b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00e1bcb4cd0c": { "name": "githubProjectTable", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index 39f6361a0ea..75a5ae8bd60 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "4438f9fd62876333bb980157612aaf457c5a9b9115659c8c941c3b373ad071dd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index 2cf32bbcfd4..09babd4e933 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace-create", "family": "settings.task-workspace-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "7e4c5bb29e0f630cda8a09233575b9295e485f3d3e315ebdc0458c69515fcfc7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0de38c54b7c1": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 9178ad4f5e6..ea1bd69ed64 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace-create", "family": "settings.task-workspace-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "1d7713cf4c23d053105c2abb02340d81d5eb689f4311a0984932d8ebd031b4ce", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "009f82649181": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index f8027c550d1..f9a8a259419 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace", "family": "settings.task-workspace", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "994ea8b4ddb05774a8c2d5902bb68bf5e8f25399a787262b8f23f458f2790698", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0de38c54b7c1": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index aadc58bbff9..42ab7198532 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "30b3f8d79589e9fb3d7ef804233554fa231f68ab88e5130ddfa78e79221e3c78", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e7f9a45facb": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index 96657659b8e..8d632791304 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "a5366cbd31d899feeb7e1901edd0c78191c2c8c8179ad5d5b24b7ca22bd538f8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e109980fe2a": { "name": "preflight.check#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index 0fd556516d5..a10cd677969 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "797ea410af6536410335ebe93b8bc354cd633cf980eb95efbb10bc46f5516cb7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09168d8f6bf2": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index 8f41fad9637..82737b545c2 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "3cd29e7b6a1cdfd99796a58cf6ad6f9aa3dbac75dd6e989ea99ba6027c210028", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06691dc6622b": { "name": "ui.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index d537ef9a273..7f1ed092d63 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-submit", "family": "settings.workspace-submit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "a89bdf93df71a958810aba72c80e42f663644781a29e934898e2ddf86c5dd5d5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "003c16e56b1f": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json index abb97747284..0291a80ae28 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.audio-chunk", "family": "speech.dictation-chunk", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "cfc84498c2ed080ca2be50725c8ad4fac84eaf2e64ecad1445997949737da11d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02cebe6f0062": { "failures": [], diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json index 8e9554098bb..7c0ace69617 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.dictation-session", "family": "speech.dictation-session", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "2cb8e1e5c1c4f9da7dcf68e3fcd51a3979966019b5872279b7347d5f09f381d5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "073a32801a55": { "error": "Cannot read properties of null (reading 'text')", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json index 1b59a18276f..0455728f889 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.dictation-session", "family": "speech.dictation-session", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "c72f6ff7dad56e5dca200f2c9c71567f0d9811c7e39a9b9aee4543f0f9d9ca0a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1bdc94d60271": { "name": "speech.dictation.finish#1", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json index 8db821475d5..ef37367d2b4 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.desktop-start", "family": "speech.dictation-start", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "ef890c668aded545c5423322aeaab0e0715ff80d5f76c6512d6579e277853f6c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02d3c28ecfb6": { "name": "speech.dictation.cancel#1", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json index 8f8cdc5dd82..1f50752b0df 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.desktop-start", "family": "speech.dictation-start", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "cd65f6b30f92d9542d19ad74cc9437ea33a1ec8fbdf4439ba13c14960f8a1994", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00ab3d9734bb": { "name": "speech.dictation.start#1", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json index 5fbae8a9192..653ba49575e 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "407a52b099cbf2fcb261010e4235dae2e5e16e11b386fb6bc99d5a2d237af541", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "05fbe06034a1": { "name": "speech.dictation.setup#1", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json index fb85e5e7d79..db6dcf6bc6c 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "140ede79a9ee0c02bedbfe67551060ef85692b6cb71968a48a1c41fcb4863804", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e61f11d0307": { "name": "speech.models.delete#1", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json index c1fd7934ead..42671d559a9 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "6c0a9d2f2e28acd10c3a1f03888c23961e95bff02af10fe0e703d34fe6d60e8b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "057063fa142d": { "configure": { diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json index ef4e546b36f..00314bc6a40 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "d63062b89cc83321f6bcbd05c55dd21c71c6ff8c8026d026b5d94e44278ed3d8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e61f11d0307": { "name": "speech.models.delete#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json index 5ac35183373..ce3c5c024dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-checks-files-github", "family": "tasks.item-checks-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "7bf61d34ba5217f7c00925612bb17542f3db17e1298294ca3ed647cd46278745", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0081ea4518b6": { "contents": { diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json index f6ddc37e0f7..0328aa21141 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-checks-files-github", "family": "tasks.item-checks-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "0186ae4ee9b3653fd9e7050a5316608a8e68f8bb463e1391862cdb3cd312676b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01a8f1e0db1b": { "name": "github.rerunPRChecks#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json index 9a921f60ec5..2560148f8ae 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-checks-files-github", "family": "tasks.item-checks-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "8f5a47f74635937c88584eeac40d2d9bec960823e7bf8f242e48575355be0d4e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01312bb7297e": { "name": "github.rerunPRChecks#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json index 66661fdfe92..f47af4cb0a2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-checks-files-github", "family": "tasks.item-checks-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "c91ad2604cacc2b02ccd727333ba66275a27f783f3adb1faf64699ea0ffa67c2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01a8f1e0db1b": { "name": "github.rerunPRChecks#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json index fb73ddfdf91..1dfe7bd9b14 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-checks-files-github", "family": "tasks.item-checks-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "984dd772f207f674d511d2711819855c7c6923d858ef87ea159842c337ed3ea0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01a8f1e0db1b": { "name": "github.rerunPRChecks#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json index 9d268655d89..3ce316f1efa 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-comment-github", "family": "tasks.item-comment-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "1cc7fdf3139e0c5a81cd83987ffedc4f59cd4866fc1b4018b61e35eacc74fc11", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f33d185219": { "draft": "a comment", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json index e898469e765..b2e5431950d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json @@ -1,28 +1,22 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-comment-gitlab", "family": "tasks.item-comment-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "154d91d00db23ea2718a6ee0b6c5cafc7233084532b3d561731b059d58e006f5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "034e6a1ee295": { + "010050033ee8": { "draft": "a comment", - "error": "", + "error": "Unknown method", "item": { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -71,78 +65,7 @@ "provider": "gitlab" } }, - "14949c71727e": { - "name": "mutatingStatus", - "ordinal": 7, - "value": false - }, - "18cc1a09cdf1": { - "draft": "a comment", - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "2c8e2127b1c0": { - "draft": "a comment", - "error": "The host sent a reply this app could not read (gitlab.addIssueComment)", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "2ef726e8c635": { + "10a85bdfd481": { "name": "gitlab.addIssueComment#1", "ordinal": 3, "args": [ @@ -155,45 +78,10 @@ "value": { "body": "a comment", "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } - }, - "354583caecf3": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -212,156 +100,20 @@ "id": "frame-1", "ok": true, "result": { - "error": "inner refused", + "error": { + "message": "inner refused" + }, "ok": false } } } }, - "35eee6cc3b14": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } + "14949c71727e": { + "name": "mutatingStatus", + "ordinal": 7, + "value": false }, - "3d836bc5f238": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "420a449a9548": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "45ecc48cbabd": { - "draft": "a comment", - "error": "Failed to add comment", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "48a9b4deaa5b": { + "17b97af91ff8": { "draft": "", "error": "", "item": { @@ -370,7 +122,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -400,7 +155,7 @@ "provider": "gitlab" } }, - "58f8191fc2d0": { + "2d33acf8eec4": { "name": "gitlab.addIssueComment#1", "ordinal": 3, "args": [ @@ -413,7 +168,48 @@ "value": { "body": "a comment", "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "3adca0741b60": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -432,24 +228,24 @@ "id": "frame-1", "ok": true, "result": { - "error": { - "message": "inner refused" - }, - "ok": false + "error": "refused" } } } }, - "597a2de36c85": { - "draft": "", - "error": "", + "4fd9a5968549": { + "draft": "a comment", + "error": "inner refused", "item": { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -466,12 +262,6 @@ "body": "a thought", "createdAt": "2020-01-01T00:00:00.000Z", "id": "comment-2" - }, - { - "author": "You", - "body": "a comment", - "createdAt": "2026-01-01T00:00:00.000Z", - "id": "local-1767225600000" } ], "labels": ["bug"], @@ -484,40 +274,7 @@ "ordinal": 5, "value": "" }, - "62e2ee2207c3": { - "draft": "a comment", - "error": "inner refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "699c19c90355": { + "67acd997f045": { "name": "gitlab.addIssueComment#1", "ordinal": 3, "args": [ @@ -530,7 +287,10 @@ "value": { "body": "a comment", "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -546,11 +306,12 @@ "startedAt": 0, "settledAt": 0, "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } + "ok": false } } }, @@ -584,7 +345,43 @@ "ordinal": 5, "value": "transport failure" }, - "6d3492ac42bc": { + "6e92494a8a50": { + "draft": "a comment", + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "747831dd5a50": { "name": "gitlab.addIssueComment#1", "ordinal": 3, "args": [ @@ -597,7 +394,483 @@ "value": { "body": "a comment", "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "74fb5653a7ad": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "776607d47471": { + "name": "error", + "ordinal": 5, + "value": "inner refused" + }, + "8ab635fff625": { + "name": "gitlab.addIssueComment#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addIssueComment\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"body\":\"a comment\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" + }, + "958ec42ac829": { + "name": "error", + "ordinal": 6, + "value": "Failed to add comment" + }, + "96f9faffad62": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "9932bdaf3500": { + "draft": "a comment", + "error": "The host sent a reply this app could not read (gitlab.addIssueComment)", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "9b61d71beea3": { + "draft": "a comment", + "error": "outer refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "ac46e56e89fe": { + "name": "error", + "ordinal": 5, + "value": "Unknown method" + }, + "ad447b1d3cad": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "b040d74ee98d": { + "draft": "a comment", + "error": "Failed to add comment", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "c7911e5c4a69": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "c7cdb095d26d": { + "name": "error", + "ordinal": 5, + "value": "The host sent a reply this app could not read (gitlab.addIssueComment)" + }, + "c9ceeaf1f519": { + "name": "reply-salvage", + "ordinal": 5, + "value": { + "droppedCount": 1, + "droppedPaths": ["error"], + "method": "gitlab.addIssueComment", + "operation": "gitlab.add-issue-comment", + "variant": "task-comment-written" + } + }, + "d69bd0a52949": { + "draft": "a comment", + "error": "transport failure", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "dfa560f5ba16": { + "draft": "", + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + }, + { + "author": "You", + "body": "a comment", + "createdAt": "2026-01-01T00:00:00.000Z", + "id": "local-1767225600000" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "e92cf39be00b": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ebbbaa5649f1": { + "name": "gitlab.addIssueComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addIssueComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -627,159 +900,6 @@ } } }, - "754f7886c608": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-1", - "ok": false - } - } - }, - "776607d47471": { - "name": "error", - "ordinal": 5, - "value": "inner refused" - }, - "7a6c84318727": { - "draft": "a comment", - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" - }, - "958ec42ac829": { - "name": "error", - "ordinal": 6, - "value": "Failed to add comment" - }, - "ac46e56e89fe": { - "name": "error", - "ordinal": 5, - "value": "Unknown method" - }, - "bc28ca26b0ad": { - "name": "gitlab.addIssueComment#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addIssueComment\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"body\":\"a comment\",\"projectRef\":\"group/project\"}}" - }, - "bf67cac66565": { - "draft": "a comment", - "error": "transport failure", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "c7cdb095d26d": { - "name": "error", - "ordinal": 5, - "value": "The host sent a reply this app could not read (gitlab.addIssueComment)" - }, - "c9ceeaf1f519": { - "name": "reply-salvage", - "ordinal": 5, - "value": { - "droppedCount": 1, - "droppedPaths": ["error"], - "method": "gitlab.addIssueComment", - "operation": "gitlab.add-issue-comment", - "variant": "task-comment-written" - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ecf9003e4ef1": { "name": "mutatingStatus", "ordinal": 6, @@ -790,41 +910,6 @@ "ordinal": 2, "value": "" }, - "f802f6e55b48": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, "f8df20507017": { "name": "error", "ordinal": 5, @@ -834,40 +919,6 @@ "name": "mutatingStatus", "ordinal": 1, "value": true - }, - "ffdbb78a70ce": { - "name": "gitlab.addIssueComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addIssueComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } } }, "recording": { @@ -876,13 +927,13 @@ { "id": "tk-item-comment-gitlab.normal:comment-settled", "observation": { - "sender": ["6d3492ac42bc"], - "payloads": ["bc28ca26b0ad"], + "sender": ["ebbbaa5649f1"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "48a9b4deaa5b", + "state": "17b97af91ff8", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -895,39 +946,39 @@ { "id": "tk-item-comment-gitlab.result-absent:comment-settled", "observation": { - "sender": ["ffdbb78a70ce"], - "payloads": ["bc28ca26b0ad"], + "sender": ["96f9faffad62"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "2c8e2127b1c0", + "state": "9932bdaf3500", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "c7cdb095d26d", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.result-null:comment-settled", "observation": { - "sender": ["699c19c90355"], - "payloads": ["bc28ca26b0ad"], + "sender": ["74fb5653a7ad"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "2c8e2127b1c0", + "state": "9932bdaf3500", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "c7cdb095d26d", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.inner-ok-missing:comment-settled", "observation": { - "sender": ["3d836bc5f238"], - "payloads": ["bc28ca26b0ad"], + "sender": ["3adca0741b60"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "597a2de36c85", + "state": "dfa560f5ba16", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -940,26 +991,26 @@ { "id": "tk-item-comment-gitlab.inner-false-string-error:comment-settled", "observation": { - "sender": ["354583caecf3"], - "payloads": ["bc28ca26b0ad"], + "sender": ["c7911e5c4a69"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "62e2ee2207c3", + "state": "4fd9a5968549", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.inner-false-object-error:comment-settled", "observation": { - "sender": ["58f8191fc2d0"], - "payloads": ["bc28ca26b0ad"], + "sender": ["10a85bdfd481"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "45ecc48cbabd", + "state": "b040d74ee98d", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -972,65 +1023,65 @@ { "id": "tk-item-comment-gitlab.outer-refused:comment-settled", "observation": { - "sender": ["2ef726e8c635"], - "payloads": ["bc28ca26b0ad"], + "sender": ["67acd997f045"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "7a6c84318727", + "state": "9b61d71beea3", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.outer-refused-no-message:comment-settled", "observation": { - "sender": ["754f7886c608"], - "payloads": ["bc28ca26b0ad"], + "sender": ["747831dd5a50"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "034e6a1ee295", + "state": "6e92494a8a50", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.method-not-found:comment-settled", "observation": { - "sender": ["35eee6cc3b14"], - "payloads": ["bc28ca26b0ad"], + "sender": ["e92cf39be00b"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "18cc1a09cdf1", + "state": "010050033ee8", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.transport-rejection:comment-settled", "observation": { - "sender": ["f802f6e55b48"], - "payloads": ["bc28ca26b0ad"], + "sender": ["2d33acf8eec4"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "bf67cac66565", + "state": "d69bd0a52949", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab.transport-rejection-no-message:comment-settled", "observation": { - "sender": ["420a449a9548"], - "payloads": ["bc28ca26b0ad"], + "sender": ["ad447b1d3cad"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "034e6a1ee295", + "state": "6e92494a8a50", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json index a1967b96b5b..9fd0ed53db5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json @@ -1,19 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-comment-gitlab-mr", "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "ae07579881829263a2b5590249d4119f5d9f4ef3877149ac3b780ad985413f00", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "01cefe2eb962": { + "0276d3629f75": { "name": "gitlab.addMRComment#1", "ordinal": 3, "args": [ @@ -26,7 +17,10 @@ "value": { "body": "a comment", "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -38,15 +32,13 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, @@ -75,269 +67,12 @@ "provider": "gitlab" } }, - "0846de0f949a": { - "draft": "a comment", - "error": "transport failure", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "12ae4dbb3007": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, "14949c71727e": { "name": "mutatingStatus", "ordinal": 7, "value": false }, - "1997c4d25500": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } - }, - "3aeeda561503": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "3b3dd5281511": { - "draft": "a comment", - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "3c584e14e862": { - "draft": "a comment", - "error": "Failed to add comment", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "564d9b281404": { - "draft": "a comment", - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "59b03dcdb55e": { - "name": "itemCommentDraft", - "ordinal": 5, - "value": "" - }, - "63b426badd37": { - "name": "gitlab.addMRComment#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addMRComment\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"body\":\"a comment\",\"projectRef\":\"group/project\"}}" - }, - "6bb96c7332db": { - "name": "error", - "ordinal": 5, - "value": "transport failure" - }, - "6c49f5e0f2ca": { + "1eb3d95fa647": { "draft": "", "error": "", "item": { @@ -346,84 +81,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" + "projectRef": { + "host": "gitlab.com", + "path": "group/project" }, - { - "author": "You", - "body": "a comment", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": 905 - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "776607d47471": { - "name": "error", - "ordinal": 5, - "value": "inner refused" - }, - "7b19f4403e25": { - "draft": "a comment", - "error": "The host sent a reply this app could not read (gitlab.addMRComment)", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "85f672c0515f": { - "draft": "", - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", "repoId": "repo-1", "state": "opened", "type": "mr" @@ -453,7 +114,7 @@ "provider": "gitlab" } }, - "8f157ddd3bbb": { + "48f9793316e8": { "name": "gitlab.addMRComment#1", "ordinal": 3, "args": [ @@ -466,251 +127,10 @@ "value": { "body": "a comment", "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "8fe54d3e49b9": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" - }, - "956c85b0fb3b": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } - }, - "958ec42ac829": { - "name": "error", - "ordinal": 6, - "value": "Failed to add comment" - }, - "a2af233162ab": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": { - "message": "inner refused" + "projectRef": { + "host": "gitlab.com", + "path": "group/project" }, - "ok": false - } - } - } - }, - "ac46e56e89fe": { - "name": "error", - "ordinal": 5, - "value": "Unknown method" - }, - "ae5917f96fbd": { - "draft": "a comment", - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "b0b348496330": { - "name": "reply-salvage", - "ordinal": 5, - "value": { - "droppedCount": 1, - "droppedPaths": ["error"], - "method": "gitlab.addMRComment", - "operation": "gitlab.add-mr-comment", - "variant": "task-comment-written" - } - }, - "b11b5f49c033": { - "name": "error", - "ordinal": 5, - "value": "The host sent a reply this app could not read (gitlab.addMRComment)" - }, - "bb90b36099bc": { - "draft": "a comment", - "error": "inner refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "de38fe098abd": { - "name": "gitlab.addMRComment#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.addMRComment" - }, - { - "name": "params", - "value": { - "body": "a comment", - "iid": 7, - "projectRef": "group/project", "repo": "id:repo-1" } }, @@ -740,7 +160,7 @@ } } }, - "e897e5f2fac0": { + "517f519c1846": { "name": "gitlab.addMRComment#1", "ordinal": 3, "args": [ @@ -753,7 +173,10 @@ "value": { "body": "a comment", "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -778,15 +201,17 @@ } } }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } + "59b03dcdb55e": { + "name": "itemCommentDraft", + "ordinal": 5, + "value": "" }, - "ec17a350c173": { + "6bb96c7332db": { + "name": "error", + "ordinal": 5, + "value": "transport failure" + }, + "6d6191ae3109": { "name": "gitlab.addMRComment#1", "ordinal": 3, "args": [ @@ -799,7 +224,259 @@ "value": { "body": "a comment", "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "75ee724c49ee": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "776607d47471": { + "name": "error", + "ordinal": 5, + "value": "inner refused" + }, + "80232e22fd7d": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "82cb8969cde1": { + "draft": "a comment", + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" + }, + "958ec42ac829": { + "name": "error", + "ordinal": 6, + "value": "Failed to add comment" + }, + "965bf7fe4cd8": { + "draft": "a comment", + "error": "Unknown method", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "9c9ce36c7219": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "a0d0d5c7045b": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -824,6 +501,344 @@ } } }, + "ac46e56e89fe": { + "name": "error", + "ordinal": 5, + "value": "Unknown method" + }, + "aff818772586": { + "name": "gitlab.addMRComment#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addMRComment\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"body\":\"a comment\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "b0b348496330": { + "name": "reply-salvage", + "ordinal": 5, + "value": { + "droppedCount": 1, + "droppedPaths": ["error"], + "method": "gitlab.addMRComment", + "operation": "gitlab.add-mr-comment", + "variant": "task-comment-written" + } + }, + "b11b5f49c033": { + "name": "error", + "ordinal": 5, + "value": "The host sent a reply this app could not read (gitlab.addMRComment)" + }, + "b6c88ff35429": { + "draft": "a comment", + "error": "inner refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d2f6881e3bb2": { + "draft": "a comment", + "error": "Failed to add comment", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d531408b1fe4": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "dfb038c94c6f": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "e86c4aff71da": { + "draft": "a comment", + "error": "outer refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "e8893ab10df1": { + "draft": "a comment", + "error": "transport failure", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "ea24a662dd3d": { + "draft": "", + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + }, + { + "author": "You", + "body": "a comment", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": 905 + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "eabbd485488b": { + "name": "gitlab.addMRComment#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.addMRComment" + }, + { + "name": "params", + "value": { + "body": "a comment", + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, "ecf9003e4ef1": { "name": "mutatingStatus", "ordinal": 6, @@ -859,6 +874,42 @@ "provider": "gitlab" } }, + "f5035dfd535c": { + "draft": "a comment", + "error": "The host sent a reply this app could not read (gitlab.addMRComment)", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "f8df20507017": { "name": "error", "ordinal": 5, @@ -876,13 +927,13 @@ { "id": "tk-item-comment-gitlab-mr.normal:comment-settled", "observation": { - "sender": ["de38fe098abd"], - "payloads": ["63b426badd37"], + "sender": ["48f9793316e8"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "6c49f5e0f2ca", + "state": "ea24a662dd3d", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -895,39 +946,39 @@ { "id": "tk-item-comment-gitlab-mr.result-absent:comment-settled", "observation": { - "sender": ["8fe54d3e49b9"], - "payloads": ["63b426badd37"], + "sender": ["eabbd485488b"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "7b19f4403e25", + "state": "f5035dfd535c", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "b11b5f49c033", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.result-null:comment-settled", "observation": { - "sender": ["12ae4dbb3007"], - "payloads": ["63b426badd37"], + "sender": ["9c9ce36c7219"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "7b19f4403e25", + "state": "f5035dfd535c", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "b11b5f49c033", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.inner-ok-missing:comment-settled", "observation": { - "sender": ["01cefe2eb962"], - "payloads": ["63b426badd37"], + "sender": ["dfb038c94c6f"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "85f672c0515f", + "state": "1eb3d95fa647", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -940,26 +991,26 @@ { "id": "tk-item-comment-gitlab-mr.inner-false-string-error:comment-settled", "observation": { - "sender": ["ec17a350c173"], - "payloads": ["63b426badd37"], + "sender": ["a0d0d5c7045b"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "bb90b36099bc", + "state": "b6c88ff35429", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.inner-false-object-error:comment-settled", "observation": { - "sender": ["a2af233162ab"], - "payloads": ["63b426badd37"], + "sender": ["75ee724c49ee"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "3c584e14e862", + "state": "d2f6881e3bb2", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -972,65 +1023,65 @@ { "id": "tk-item-comment-gitlab-mr.outer-refused:comment-settled", "observation": { - "sender": ["956c85b0fb3b"], - "payloads": ["63b426badd37"], + "sender": ["80232e22fd7d"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "564d9b281404", + "state": "e86c4aff71da", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.outer-refused-no-message:comment-settled", "observation": { - "sender": ["e897e5f2fac0"], - "payloads": ["63b426badd37"], + "sender": ["517f519c1846"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "ae5917f96fbd", + "state": "82cb8969cde1", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.method-not-found:comment-settled", "observation": { - "sender": ["1997c4d25500"], - "payloads": ["63b426badd37"], + "sender": ["d531408b1fe4"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "3b3dd5281511", + "state": "965bf7fe4cd8", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.transport-rejection:comment-settled", "observation": { - "sender": ["8f157ddd3bbb"], - "payloads": ["63b426badd37"], + "sender": ["0276d3629f75"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "0846de0f949a", + "state": "e8893ab10df1", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-comment-gitlab-mr.transport-rejection-no-message:comment-settled", "observation": { - "sender": ["3aeeda561503"], - "payloads": ["63b426badd37"], + "sender": ["6d6191ae3109"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "ae5917f96fbd", + "state": "82cb8969cde1", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json index d0aa49fbb85..3802193c576 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-github", "family": "tasks.item-detail-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "955729f9100dce7eeb103f7b4d08ff56ac66853b9ad0d33627c0838011287bca", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1874e6e64ab8": { "error": "", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json index b13a5344e8c..61c64729d54 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json @@ -1,19 +1,82 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-gitlab", "family": "tasks.item-detail-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "d163c6125fa180da7575642ee29f4bb18e3678079b7c30a42934550896d5b3c1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "17a248d44317": { + "012013790766": { + "name": "actionItem", + "ordinal": 7, + "value": { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + }, + "045b7b3f47e6": { + "error": "Details not found", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "$rpc": "null" + } + }, + "05a9c785c3cb": { "name": "gitlab.workItemDetails#1", "ordinal": 4, "args": [ @@ -25,7 +88,879 @@ "name": "params", "value": { "iid": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "1e333ed80a4e": { + "name": "detailError", + "ordinal": 6, + "value": "" + }, + "1fb950c5f545": { + "name": "detailError", + "ordinal": 6, + "value": "outer refused" + }, + "2036b7d39273": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "20dbc6200418": { + "name": "gitlab.workItemDetails#1", + "ordinal": 5, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemDetails\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":4,\"type\":\"issue\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "2132e677817b": { + "error": "Unknown method", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "$rpc": "null" + } + }, + "255b05b3c8e0": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "assignees": [], + "body": "", + "comments": [], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "25700e7ac4e4": { + "error": "The host sent a reply this app could not read (gitlab.workItemDetails)", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "$rpc": "null" + } + }, + "264010756b38": { + "name": "detailLoading", + "ordinal": 3, + "value": true + }, + "2a2a4d23e834": { + "name": "actionItem", + "ordinal": 7, + "value": { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + }, + "33614c90b6eb": { + "name": "detailPayload", + "ordinal": 1, + "value": { + "$rpc": "null" + } + }, + "4cc7457d7059": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "589b0f104d76": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "592a7011d0de": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "612bc240a5da": { + "name": "items", + "ordinal": 8, + "value": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ] + }, + "67ac79c324d1": { + "name": "items", + "ordinal": 8, + "value": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ] + }, + "6a8913577681": { + "name": "detailPayload", + "ordinal": 6, + "value": { + "assignees": [], + "body": "body", + "comments": [], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "6fac44c7cb30": { + "name": "detailError", + "ordinal": 6, + "value": "Unknown method" + }, + "7816d31432b8": { + "name": "detailLoading", + "ordinal": 7, + "value": false + }, + "81e387573ce6": { + "name": "detailError", + "ordinal": 6, + "value": "The host sent a reply this app could not read (gitlab.workItemDetails)" + }, + "8ccd4fa759a5": { + "name": "detailLoading", + "ordinal": 9, + "value": false + }, + "923a1c0af8db": { + "name": "detailError", + "ordinal": 6, + "value": "Details not found" + }, + "92a5dcfabad9": { + "name": "detailError", + "ordinal": 6, + "value": "transport failure" + }, + "a17b0c7203a5": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "a4be36bff4cc": { + "error": "transport failure", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "$rpc": "null" + } + }, + "a4f1a8696a24": { + "name": "detailError", + "ordinal": 2, + "value": "" + }, + "ae50888179e3": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "bf8fa9426ea2": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "c92b0e37085c": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "cd4bfb5432c6": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "$rpc": "null" + } + }, + "cf8647d8cb78": { + "name": "detailPayload", + "ordinal": 6, + "value": { + "assignees": [], + "body": "", + "comments": [], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "cfffc75a3055": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d041fad8fe5d": { + "error": "outer refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "$rpc": "null" + } + }, + "e9b468d599e5": { + "name": "gitlab.workItemDetails#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "gitlab.workItemDetails" + }, + { + "name": "params", + "value": { + "iid": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "type": "issue" } @@ -62,17 +997,15 @@ } } }, - "1dc35b955ad0": { - "name": "gitlab.workItemDetails#1", - "ordinal": 5, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemDetails\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":4,\"type\":\"issue\",\"projectRef\":\"group/project\"}}" + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } }, - "1e333ed80a4e": { - "name": "detailError", - "ordinal": 6, - "value": "" - }, - "1e50646d901f": { + "f545c399acf8": { "name": "gitlab.workItemDetails#1", "ordinal": 4, "args": [ @@ -84,7 +1017,10 @@ "name": "params", "value": { "iid": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "type": "issue" } @@ -111,858 +1047,6 @@ } } } - }, - "1fb950c5f545": { - "name": "detailError", - "ordinal": 6, - "value": "outer refused" - }, - "264010756b38": { - "name": "detailLoading", - "ordinal": 3, - "value": true - }, - "2cd97e21e420": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } - }, - "33614c90b6eb": { - "name": "detailPayload", - "ordinal": 1, - "value": { - "$rpc": "null" - } - }, - "4095377ba312": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "422d12d6bbc9": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-1", - "ok": false - } - } - }, - "512d9889102f": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, - "54b45f2a8384": { - "name": "actionItem", - "ordinal": 7, - "value": { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - }, - "5672ba1c0594": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "assignees": [], - "body": "", - "comments": [], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "59ce2e7cea2f": { - "name": "items", - "ordinal": 8, - "value": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ] - }, - "5a29c0f1892b": { - "name": "actionItem", - "ordinal": 7, - "value": { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - }, - "62edf8d0f053": { - "error": "The host sent a reply this app could not read (gitlab.workItemDetails)", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "$rpc": "null" - } - }, - "6a8913577681": { - "name": "detailPayload", - "ordinal": 6, - "value": { - "assignees": [], - "body": "body", - "comments": [], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "6fac44c7cb30": { - "name": "detailError", - "ordinal": 6, - "value": "Unknown method" - }, - "77508b341768": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "7816d31432b8": { - "name": "detailLoading", - "ordinal": 7, - "value": false - }, - "7ed24390e683": { - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "$rpc": "null" - } - }, - "81e387573ce6": { - "name": "detailError", - "ordinal": 6, - "value": "The host sent a reply this app could not read (gitlab.workItemDetails)" - }, - "8ccd4fa759a5": { - "name": "detailLoading", - "ordinal": 9, - "value": false - }, - "923a1c0af8db": { - "name": "detailError", - "ordinal": 6, - "value": "Details not found" - }, - "92a5dcfabad9": { - "name": "detailError", - "ordinal": 6, - "value": "transport failure" - }, - "9b89e6739339": { - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "$rpc": "null" - } - }, - "a4f1a8696a24": { - "name": "detailError", - "ordinal": 2, - "value": "" - }, - "cf8647d8cb78": { - "name": "detailPayload", - "ordinal": 6, - "value": { - "assignees": [], - "body": "", - "comments": [], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "d6080941671f": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "db0c674d34f9": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "$rpc": "null" - } - }, - "db4683bb216e": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "ed88b2b061f9": { - "error": "transport failure", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "$rpc": "null" - } - }, - "f24316a2872c": { - "error": "Details not found", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "$rpc": "null" - } - }, - "f2d8814a60b2": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "f6cd6c3cf1a2": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "fa9ae39d069e": { - "name": "items", - "ordinal": 8, - "value": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ] - }, - "fb900662a1c3": { - "name": "gitlab.workItemDetails#1", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "gitlab.workItemDetails" - }, - { - "name": "params", - "value": { - "iid": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "type": "issue" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } } }, "recording": { @@ -971,19 +1055,19 @@ { "id": "tk-item-detail-gitlab.normal:mounted", "observation": { - "sender": ["17a248d44317"], - "payloads": ["1dc35b955ad0"], + "sender": ["e9b468d599e5"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "f2d8814a60b2", + "state": "cfffc75a3055", "effects": [ "33614c90b6eb", "a4f1a8696a24", "264010756b38", "6a8913577681", - "5a29c0f1892b", - "fa9ae39d069e", + "012013790766", + "612bc240a5da", "8ccd4fa759a5" ] } @@ -991,12 +1075,12 @@ { "id": "tk-item-detail-gitlab.result-absent:mounted", "observation": { - "sender": ["512d9889102f"], - "payloads": ["1dc35b955ad0"], + "sender": ["4cc7457d7059"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "62edf8d0f053", + "state": "25700e7ac4e4", "effects": [ "33614c90b6eb", "a4f1a8696a24", @@ -1009,12 +1093,12 @@ { "id": "tk-item-detail-gitlab.result-null:mounted", "observation": { - "sender": ["4095377ba312"], - "payloads": ["1dc35b955ad0"], + "sender": ["05a9c785c3cb"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "f24316a2872c", + "state": "045b7b3f47e6", "effects": [ "33614c90b6eb", "a4f1a8696a24", @@ -1027,19 +1111,19 @@ { "id": "tk-item-detail-gitlab.inner-ok-missing:mounted", "observation": { - "sender": ["db4683bb216e"], - "payloads": ["1dc35b955ad0"], + "sender": ["bf8fa9426ea2"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "5672ba1c0594", + "state": "255b05b3c8e0", "effects": [ "33614c90b6eb", "a4f1a8696a24", "264010756b38", "cf8647d8cb78", - "54b45f2a8384", - "59ce2e7cea2f", + "2a2a4d23e834", + "67ac79c324d1", "8ccd4fa759a5" ] } @@ -1047,19 +1131,19 @@ { "id": "tk-item-detail-gitlab.inner-false-string-error:mounted", "observation": { - "sender": ["f6cd6c3cf1a2"], - "payloads": ["1dc35b955ad0"], + "sender": ["a17b0c7203a5"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "5672ba1c0594", + "state": "255b05b3c8e0", "effects": [ "33614c90b6eb", "a4f1a8696a24", "264010756b38", "cf8647d8cb78", - "54b45f2a8384", - "59ce2e7cea2f", + "2a2a4d23e834", + "67ac79c324d1", "8ccd4fa759a5" ] } @@ -1067,19 +1151,19 @@ { "id": "tk-item-detail-gitlab.inner-false-object-error:mounted", "observation": { - "sender": ["1e50646d901f"], - "payloads": ["1dc35b955ad0"], + "sender": ["f545c399acf8"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "5672ba1c0594", + "state": "255b05b3c8e0", "effects": [ "33614c90b6eb", "a4f1a8696a24", "264010756b38", "cf8647d8cb78", - "54b45f2a8384", - "59ce2e7cea2f", + "2a2a4d23e834", + "67ac79c324d1", "8ccd4fa759a5" ] } @@ -1087,12 +1171,12 @@ { "id": "tk-item-detail-gitlab.outer-refused:mounted", "observation": { - "sender": ["fb900662a1c3"], - "payloads": ["1dc35b955ad0"], + "sender": ["2036b7d39273"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "9b89e6739339", + "state": "d041fad8fe5d", "effects": [ "33614c90b6eb", "a4f1a8696a24", @@ -1105,12 +1189,12 @@ { "id": "tk-item-detail-gitlab.outer-refused-no-message:mounted", "observation": { - "sender": ["422d12d6bbc9"], - "payloads": ["1dc35b955ad0"], + "sender": ["c92b0e37085c"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "db0c674d34f9", + "state": "cd4bfb5432c6", "effects": [ "33614c90b6eb", "a4f1a8696a24", @@ -1123,12 +1207,12 @@ { "id": "tk-item-detail-gitlab.method-not-found:mounted", "observation": { - "sender": ["2cd97e21e420"], - "payloads": ["1dc35b955ad0"], + "sender": ["592a7011d0de"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "7ed24390e683", + "state": "2132e677817b", "effects": [ "33614c90b6eb", "a4f1a8696a24", @@ -1141,12 +1225,12 @@ { "id": "tk-item-detail-gitlab.transport-rejection:mounted", "observation": { - "sender": ["77508b341768"], - "payloads": ["1dc35b955ad0"], + "sender": ["589b0f104d76"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "ed88b2b061f9", + "state": "a4be36bff4cc", "effects": [ "33614c90b6eb", "a4f1a8696a24", @@ -1159,12 +1243,12 @@ { "id": "tk-item-detail-gitlab.transport-rejection-no-message:mounted", "observation": { - "sender": ["d6080941671f"], - "payloads": ["1dc35b955ad0"], + "sender": ["ae50888179e3"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "db0c674d34f9", + "state": "cd4bfb5432c6", "effects": [ "33614c90b6eb", "a4f1a8696a24", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json index 82147afb79a..1a926ce7238 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-linear", "family": "tasks.item-detail-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "83b30b2a160d162d16c66aa3bf6a633489e86dd4fd1c30828d2d244164b9c95e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "055f2a304d6c": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json index 83739928034..cd6a421bd6b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-linear", "family": "tasks.item-detail-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "06b06c84f4b8a1ee8e5159d8ada2656da4d7096729759c76601ad4e251310924", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0344292bbcfb": { "name": "linear.issueComments#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json index 0bf04e34736..70ca251a60f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-metadata", "family": "tasks.item-detail-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", - "scenarioSha256": "3ef6ef046f60d2d11ef81adf69bd5216036405eb05e5fd30cf1c402120488ee5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "046247fe2cbb": { "labels": ["bug", "chore"], diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json index 8a725b43c2b..8b3d7d60bcf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-metadata", "family": "tasks.item-detail-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", - "scenarioSha256": "a2ecf4ddc2c9870a8d73cbd920b46bb0663a958ac156dc6de092be3c9474ea2b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bfb51d103da": { "name": "itemLabelsError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json index f078de63746..650afdccbba 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json @@ -1,66 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-merge-gitlab", "family": "tasks.item-merge-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "f40d99c60523ad1a6a3a761935e86ab0739337486dd316e6ab3248980e575027", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "0687dba3171a": { - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "0a9bda03bdd0": { + "024c8df6252c": { "name": "gitlab.mergeMR#1", "ordinal": 3, "args": [ @@ -73,86 +17,10 @@ "value": { "iid": 7, "method": "squash", - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 60000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "132f75695e24": { - "name": "gitlab.mergeMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.mergeMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "method": "squash", - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 60000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "14949c71727e": { - "name": "mutatingStatus", - "ordinal": 7, - "value": false - }, - "1be492d81713": { - "name": "gitlab.mergeMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.mergeMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "method": "squash", - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -177,7 +45,65 @@ } } }, - "370597a7fb42": { + "0966cdd17662": { + "error": "Failed to merge", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "14949c71727e": { + "name": "mutatingStatus", + "ordinal": 7, + "value": false + }, + "1a59455adf4e": { "name": "gitlab.mergeMR#1", "ordinal": 3, "args": [ @@ -190,7 +116,10 @@ "value": { "iid": 7, "method": "squash", - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -215,6 +144,136 @@ } } }, + "267ac04d2508": { + "name": "gitlab.mergeMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.mergeMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "method": "squash", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 60000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "3272022b3a4e": { + "name": "gitlab.mergeMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.mergeMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "method": "squash", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 60000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "ok": true + } + } + } + }, + "37c27ca402e4": { + "error": "outer refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "3ea7248d382b": { "name": "actionItem", "ordinal": 5, @@ -227,47 +286,158 @@ "ordinal": 6, "value": "Failed to merge" }, + "53a900ff7ec7": { + "error": "The host sent a reply this app could not read (gitlab.mergeMR)", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "55cefee0a14c": { "name": "error", "ordinal": 5, "value": "The host sent a reply this app could not read (gitlab.mergeMR)" }, - "594cabccd04a": { - "name": "gitlab.mergeMR#1", - "ordinal": 3, - "args": [ + "589373883844": { + "error": "", + "item": { + "$rpc": "null" + }, + "items": [ { - "name": "method", - "value": "gitlab.mergeMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "method": "squash", - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 60000 - } + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" } ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" } }, - "6358a93881bf": { + "5a043e84e661": { + "error": "Unknown method", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "60cdd9fbfd99": { "name": "gitlab.mergeMR#1", "ordinal": 3, "args": [ @@ -280,7 +450,10 @@ "value": { "iid": 7, "method": "squash", - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -307,105 +480,11 @@ } } }, - "69841347ee06": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "6bb96c7332db": { "name": "error", "ordinal": 5, "value": "transport failure" }, - "702351d98030": { - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "75cccb1de940": { "name": "reply-salvage", "ordinal": 5, @@ -422,12 +501,7 @@ "ordinal": 5, "value": "inner refused" }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" - }, - "928d97437d5b": { + "81b0c2492839": { "name": "gitlab.mergeMR#1", "ordinal": 3, "args": [ @@ -440,7 +514,197 @@ "value": { "iid": 7, "method": "squash", - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 60000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" + }, + "a128e7836bc1": { + "name": "gitlab.mergeMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.mergeMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "method": "squash", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 60000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "ac46e56e89fe": { + "name": "error", + "ordinal": 5, + "value": "Unknown method" + }, + "af47883b3f3b": { + "name": "gitlab.mergeMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.mergeMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "method": "squash", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 60000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "af6d48e640c6": { + "name": "gitlab.mergeMR#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.mergeMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"method\":\"squash\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "b5f237e60c83": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "bdafc6458589": { + "name": "gitlab.mergeMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.mergeMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "method": "squash", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -465,7 +729,7 @@ } } }, - "98354008c52b": { + "c566e36ed73d": { "error": "inner refused", "item": { "provider": "gitlab", @@ -473,7 +737,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -487,301 +754,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "ac46e56e89fe": { - "name": "error", - "ordinal": 5, - "value": "Unknown method" - }, - "af959aaccf63": { - "name": "gitlab.mergeMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.mergeMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "method": "squash", - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 60000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "ok": true - } - } - } - }, - "b5816f2f29eb": { - "name": "gitlab.mergeMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.mergeMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "method": "squash", - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 60000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, - "b6a6630b4d40": { - "error": "", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "bc0818479b28": { - "error": "The host sent a reply this app could not read (gitlab.mergeMR)", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "c6efd8599d7e": { - "name": "gitlab.mergeMR#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.mergeMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"method\":\"squash\",\"projectRef\":\"group/project\"}}" - }, - "c8b8509f0654": { - "name": "gitlab.mergeMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.mergeMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "method": "squash", - "projectRef": "group/project", - "repo": "id:repo-1" - } - }, - { - "name": "options", - "value": { - "timeoutMs": 60000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "d6f17e3de7da": { - "error": "transport failure", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "e1b5dd2a1c1e": { - "error": "Failed to merge", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -824,12 +800,65 @@ "ordinal": 2, "value": "" }, + "ef61a5798943": { + "error": "transport failure", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "f8df20507017": { "name": "error", "ordinal": 5, "value": "" }, - "fc5b0de18d05": { + "f8f69ad68747": { "name": "gitlab.mergeMR#1", "ordinal": 3, "args": [ @@ -842,7 +871,48 @@ "value": { "iid": 7, "method": "squash", - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 60000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "fa405b3f88ea": { + "name": "gitlab.mergeMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.mergeMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "method": "squash", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -858,12 +928,11 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "refused", - "message": "" - }, "id": "frame-1", - "ok": false + "ok": true, + "result": { + "error": "refused" + } } } }, @@ -879,78 +948,78 @@ { "id": "tk-item-merge-gitlab.normal:merge-settled", "observation": { - "sender": ["af959aaccf63"], - "payloads": ["c6efd8599d7e"], + "sender": ["3272022b3a4e"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "b6a6630b4d40", + "state": "589373883844", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.result-absent:merge-settled", "observation": { - "sender": ["b5816f2f29eb"], - "payloads": ["c6efd8599d7e"], + "sender": ["267ac04d2508"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "bc0818479b28", + "state": "53a900ff7ec7", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "55cefee0a14c", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.result-null:merge-settled", "observation": { - "sender": ["0a9bda03bdd0"], - "payloads": ["c6efd8599d7e"], + "sender": ["81b0c2492839"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "bc0818479b28", + "state": "53a900ff7ec7", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "55cefee0a14c", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.inner-ok-missing:merge-settled", "observation": { - "sender": ["132f75695e24"], - "payloads": ["c6efd8599d7e"], + "sender": ["fa405b3f88ea"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "b6a6630b4d40", + "state": "589373883844", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.inner-false-string-error:merge-settled", "observation": { - "sender": ["1be492d81713"], - "payloads": ["c6efd8599d7e"], + "sender": ["024c8df6252c"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "98354008c52b", + "state": "c566e36ed73d", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.inner-false-object-error:merge-settled", "observation": { - "sender": ["6358a93881bf"], - "payloads": ["c6efd8599d7e"], + "sender": ["60cdd9fbfd99"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "e1b5dd2a1c1e", + "state": "0966cdd17662", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -963,65 +1032,65 @@ { "id": "tk-item-merge-gitlab.outer-refused:merge-settled", "observation": { - "sender": ["370597a7fb42"], - "payloads": ["c6efd8599d7e"], + "sender": ["1a59455adf4e"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "702351d98030", + "state": "37c27ca402e4", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.outer-refused-no-message:merge-settled", "observation": { - "sender": ["fc5b0de18d05"], - "payloads": ["c6efd8599d7e"], + "sender": ["a128e7836bc1"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "69841347ee06", + "state": "b5f237e60c83", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.method-not-found:merge-settled", "observation": { - "sender": ["928d97437d5b"], - "payloads": ["c6efd8599d7e"], + "sender": ["bdafc6458589"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "0687dba3171a", + "state": "5a043e84e661", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.transport-rejection:merge-settled", "observation": { - "sender": ["c8b8509f0654"], - "payloads": ["c6efd8599d7e"], + "sender": ["af47883b3f3b"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "d6f17e3de7da", + "state": "ef61a5798943", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-merge-gitlab.transport-rejection-no-message:merge-settled", "observation": { - "sender": ["594cabccd04a"], - "payloads": ["c6efd8599d7e"], + "sender": ["f8f69ad68747"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "69841347ee06", + "state": "b5f237e60c83", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json index 07caea1146c..378b40af47f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-metadata-github", "family": "tasks.item-metadata-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "27587bfb5745051e3cb27b01dc49b90b6f3c8ddbeb38f20772502fae2f562d91", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0dadb476987d": { "name": "reply-salvage", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json index ad5b2516e9a..9d45c3dd8d0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json @@ -1,19 +1,164 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-metadata-gitlab", "family": "tasks.item-metadata-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "01664112d8f24d0a08fa7ba4e2d7f389acb363ce493e083f359f90fa0b87911d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "06ebfa394e4c": { + "0f541a5aff5d": { + "error": "Failed to update GitLab item", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "12b7e5d9f599": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "title": "Renamed" + } + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "14949c71727e": { + "name": "mutatingStatus", + "ordinal": 7, + "value": false + }, + "15de9730d149": { + "error": "The host sent a reply this app could not read (gitlab.updateIssue)", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "1607ea6e021e": { "error": "", "item": { "provider": "gitlab", @@ -21,7 +166,10 @@ "id": "gitlab:issue:4", "labels": ["bug", "triage"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "title": "Renamed", @@ -36,7 +184,10 @@ "id": "gitlab:issue:4", "labels": ["bug", "triage"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "title": "Renamed", @@ -62,15 +213,72 @@ "provider": "gitlab" } }, - "0a5028edd717": { - "error": "inner refused", + "2912ca1810e3": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "title": "Renamed" + } + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "2a33d3e3468b": { + "name": "error", + "ordinal": 6, + "value": "Failed to update GitLab item" + }, + "2edac82b3b9a": { + "name": "itemRemoveAssigneesDraft", + "ordinal": 11, + "value": "" + }, + "39fb66a9e6ea": { + "error": "outer refused", "item": { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -84,7 +292,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -109,12 +320,7 @@ "provider": "gitlab" } }, - "14949c71727e": { - "name": "mutatingStatus", - "ordinal": 7, - "value": false - }, - "176521f93bc7": { + "3ac4af8379a7": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -126,45 +332,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "27cf854619a1": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -193,15 +364,18 @@ } } }, - "29c448841f73": { - "error": "The host sent a reply this app could not read (gitlab.updateIssue)", + "3af68e0534be": { + "error": "inner refused", "item": { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -215,7 +389,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -240,16 +417,6 @@ "provider": "gitlab" } }, - "2a33d3e3468b": { - "name": "error", - "ordinal": 6, - "value": "Failed to update GitLab item" - }, - "2edac82b3b9a": { - "name": "itemRemoveAssigneesDraft", - "ordinal": 11, - "value": "" - }, "3b10c3bd94b0": { "name": "reply-salvage", "ordinal": 5, @@ -261,7 +428,7 @@ "variant": "task-item-mutation" } }, - "456886cb46f5": { + "3d201756a9f7": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -273,7 +440,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -301,7 +471,7 @@ } } }, - "68823c4667bf": { + "685e1bce76e5": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -313,7 +483,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -329,12 +502,13 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, @@ -348,17 +522,7 @@ "ordinal": 5, "value": "The host sent a reply this app could not read (gitlab.updateIssue)" }, - "776607d47471": { - "name": "error", - "ordinal": 5, - "value": "inner refused" - }, - "885570183416": { - "name": "gitlab.updateIssue#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]},\"projectRef\":\"group/project\"}}" - }, - "8c442f64a0dc": { + "6d09f297c6ee": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -370,104 +534,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" - }, - "9de8a1be3f13": { - "name": "mutatingStatus", - "ordinal": 12, - "value": false - }, - "a3139ecf7ce9": { - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "a372249e4dd9": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -495,7 +565,22 @@ } } }, - "a85803b27ae1": { + "776607d47471": { + "name": "error", + "ordinal": 5, + "value": "inner refused" + }, + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" + }, + "9de8a1be3f13": { + "name": "mutatingStatus", + "ordinal": 12, + "value": false + }, + "a484e94ad6b4": { "error": "transport failure", "item": { "provider": "gitlab", @@ -503,7 +588,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -517,7 +605,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -547,7 +638,7 @@ "ordinal": 5, "value": "Unknown method" }, - "afc1f3d9769e": { + "b12696d79e5a": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -559,7 +650,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -579,12 +673,8 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "refused", - "message": "" - }, "id": "frame-1", - "ok": false + "ok": true } } }, @@ -593,7 +683,7 @@ "ordinal": 8, "value": "" }, - "b7c486cbae9c": { + "bd63cda77d53": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -605,7 +695,191 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "title": "Renamed" + } + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "c00088248e96": { + "error": "Unknown method", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "c217dd32a83b": { + "name": "gitlab.updateIssue#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]},\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "c43f4fb1b61f": { + "name": "actionItem", + "ordinal": 5, + "value": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug", "triage"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "issue" + }, + "title": "Renamed" + } + }, + "cbedc96ba8c3": { + "name": "itemAddAssigneesDraft", + "ordinal": 10, + "value": "" + }, + "d45458f553ea": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d46af61d57a7": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -631,7 +905,67 @@ } } }, - "bc6998fa388b": { + "d6b74d32804d": { + "name": "detailPayload", + "ordinal": 7, + "value": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug", "triage"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "e2912ad4d0f1": { + "name": "items", + "ordinal": 6, + "value": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug", "triage"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "issue" + }, + "title": "Renamed" + } + ] + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ecf9003e4ef1": { + "name": "mutatingStatus", + "ordinal": 6, + "value": false + }, + "ed3a7d6bc894": { + "name": "error", + "ordinal": 2, + "value": "" + }, + "ee04a262afa4": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -643,7 +977,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -674,190 +1011,22 @@ } } }, - "c02252fb214d": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "c555f8c27d97": { - "error": "Failed to update GitLab item", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "c7ff417f5a6d": { - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "cbb029dd26e9": { - "name": "actionItem", - "ordinal": 5, - "value": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug", "triage"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "issue" - }, - "title": "Renamed" - } - }, - "cbedc96ba8c3": { - "name": "itemAddAssigneesDraft", - "ordinal": 10, + "f11c308f1979": { + "name": "itemRemoveLabelsDraft", + "ordinal": 9, "value": "" }, - "d6b74d32804d": { - "name": "detailPayload", - "ordinal": 7, - "value": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug", "triage"], - "pipelineJobs": [], - "provider": "gitlab" - } + "f8df20507017": { + "name": "error", + "ordinal": 5, + "value": "" }, - "d90bda3ec80a": { + "fd2fad47bec2": { + "name": "mutatingStatus", + "ordinal": 1, + "value": true + }, + "fd7c5006df01": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -869,48 +1038,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "e7494e2a0744": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -931,66 +1062,13 @@ "settledAt": 0, "value": { "error": { - "code": "method_not_found", - "message": "Unknown method" + "code": "refused", + "message": "" }, "id": "frame-1", "ok": false } } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "ecf9003e4ef1": { - "name": "mutatingStatus", - "ordinal": 6, - "value": false - }, - "ed3a7d6bc894": { - "name": "error", - "ordinal": 2, - "value": "" - }, - "f11c308f1979": { - "name": "itemRemoveLabelsDraft", - "ordinal": 9, - "value": "" - }, - "f8df20507017": { - "name": "error", - "ordinal": 5, - "value": "" - }, - "fb7a40f606c1": { - "name": "items", - "ordinal": 6, - "value": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug", "triage"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "issue" - }, - "title": "Renamed" - } - ] - }, - "fd2fad47bec2": { - "name": "mutatingStatus", - "ordinal": 1, - "value": true } }, "recording": { @@ -999,18 +1077,18 @@ { "id": "tk-item-metadata-gitlab.normal:update-gitlab-settled", "observation": { - "sender": ["a372249e4dd9"], - "payloads": ["885570183416"], + "sender": ["6d09f297c6ee"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "06ebfa394e4c", + "state": "1607ea6e021e", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", - "cbb029dd26e9", - "fb7a40f606c1", + "c43f4fb1b61f", + "e2912ad4d0f1", "d6b74d32804d", "b4055ff60394", "f11c308f1979", @@ -1023,44 +1101,44 @@ { "id": "tk-item-metadata-gitlab.result-absent:update-gitlab-settled", "observation": { - "sender": ["68823c4667bf"], - "payloads": ["885570183416"], + "sender": ["b12696d79e5a"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "29c448841f73", + "state": "15de9730d149", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6c731a45bff2", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.result-null:update-gitlab-settled", "observation": { - "sender": ["8c442f64a0dc"], - "payloads": ["885570183416"], + "sender": ["12b7e5d9f599"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "29c448841f73", + "state": "15de9730d149", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6c731a45bff2", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.inner-ok-missing:update-gitlab-settled", "observation": { - "sender": ["456886cb46f5"], - "payloads": ["885570183416"], + "sender": ["3d201756a9f7"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "06ebfa394e4c", + "state": "1607ea6e021e", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", - "cbb029dd26e9", - "fb7a40f606c1", + "c43f4fb1b61f", + "e2912ad4d0f1", "d6b74d32804d", "b4055ff60394", "f11c308f1979", @@ -1073,26 +1151,26 @@ { "id": "tk-item-metadata-gitlab.inner-false-string-error:update-gitlab-settled", "observation": { - "sender": ["d90bda3ec80a"], - "payloads": ["885570183416"], + "sender": ["bd63cda77d53"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "0a5028edd717", + "state": "3af68e0534be", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.inner-false-object-error:update-gitlab-settled", "observation": { - "sender": ["bc6998fa388b"], - "payloads": ["885570183416"], + "sender": ["ee04a262afa4"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "c555f8c27d97", + "state": "0f541a5aff5d", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1105,65 +1183,65 @@ { "id": "tk-item-metadata-gitlab.outer-refused:update-gitlab-settled", "observation": { - "sender": ["27cf854619a1"], - "payloads": ["885570183416"], + "sender": ["3ac4af8379a7"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "c7ff417f5a6d", + "state": "39fb66a9e6ea", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.outer-refused-no-message:update-gitlab-settled", "observation": { - "sender": ["afc1f3d9769e"], - "payloads": ["885570183416"], + "sender": ["fd7c5006df01"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.method-not-found:update-gitlab-settled", "observation": { - "sender": ["e7494e2a0744"], - "payloads": ["885570183416"], + "sender": ["2912ca1810e3"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "a3139ecf7ce9", + "state": "c00088248e96", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.transport-rejection:update-gitlab-settled", "observation": { - "sender": ["176521f93bc7"], - "payloads": ["885570183416"], + "sender": ["685e1bce76e5"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "a85803b27ae1", + "state": "a484e94ad6b4", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab.transport-rejection-no-message:update-gitlab-settled", "observation": { - "sender": ["b7c486cbae9c"], - "payloads": ["885570183416"], + "sender": ["d46af61d57a7"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json index 93e2a9c0138..79e4b816acd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json @@ -1,148 +1,15 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-metadata-gitlab-mr", "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "186444ac8dc34dc63d1fbf304275e2265d96c6742e4c8f1e8e5568aa5504bf5d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "0687dba3171a": { - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "14949c71727e": { "name": "mutatingStatus", "ordinal": 7, "value": false }, - "231f5648a833": { - "name": "gitlab.updateMR#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"projectRef\":\"group/project\",\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]}}}" - }, - "2a33d3e3468b": { - "name": "error", - "ordinal": 6, - "value": "Failed to update GitLab item" - }, - "438d6fb98dc1": { - "error": "Failed to update GitLab item", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "510654109df9": { - "name": "items", - "ordinal": 6, - "value": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - } - ] - }, - "52952495ce9f": { + "1baaae5c9ed0": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -154,62 +21,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "body": { - "$rpc": "undefined" - }, - "removeLabels": { - "$rpc": "undefined" - }, - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "58a340d11739": { - "name": "reply-salvage", - "ordinal": 5, - "value": { - "droppedCount": 1, - "droppedPaths": ["error"], - "method": "gitlab.updateMR", - "operation": "gitlab.update-merge-request", - "variant": "task-item-mutation" - } - }, - "59642c50afeb": { - "name": "gitlab.updateMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -235,190 +50,15 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, "id": "frame-1", - "ok": false - } - } - }, - "5c50471cdd48": { - "name": "mutatingStatus", - "ordinal": 10, - "value": false - }, - "69841347ee06": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" + "ok": true, + "result": { + "ok": true } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "6b479af80b3b": { - "name": "actionItem", - "ordinal": 5, - "value": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - } - }, - "6bb96c7332db": { - "name": "error", - "ordinal": 5, - "value": "transport failure" - }, - "702351d98030": { - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" } }, - "776607d47471": { - "name": "error", - "ordinal": 5, - "value": "inner refused" - }, - "82de13ba46dd": { - "error": "The host sent a reply this app could not read (gitlab.updateMR)", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "8b75d0a6b159": { + "20dac44c5345": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -430,7 +70,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -464,20 +107,73 @@ } } }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" + "266686bfdeec": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug", "triage"], + "pipelineJobs": [], + "provider": "gitlab" + } }, - "98354008c52b": { - "error": "inner refused", + "29c6b20b5026": { + "error": "Failed to update GitLab item", "item": { "provider": "gitlab", "source": { "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -491,7 +187,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -516,7 +215,86 @@ "provider": "gitlab" } }, - "a1646d9e9194": { + "2a33d3e3468b": { + "name": "error", + "ordinal": 6, + "value": "Failed to update GitLab item" + }, + "2f03f52f6db7": { + "name": "actionItem", + "ordinal": 5, + "value": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + } + }, + "37c27ca402e4": { + "error": "outer refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "46041f85f179": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -528,7 +306,109 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "body": { + "$rpc": "undefined" + }, + "removeLabels": { + "$rpc": "undefined" + }, + "title": "Renamed" + } + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "565d4ea436d2": { + "name": "gitlab.updateMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "body": { + "$rpc": "undefined" + }, + "removeLabels": { + "$rpc": "undefined" + }, + "title": "Renamed" + } + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "57cef18396c5": { + "name": "gitlab.updateMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -563,7 +443,76 @@ } } }, - "a6357bef2eec": { + "58a340d11739": { + "name": "reply-salvage", + "ordinal": 5, + "value": { + "droppedCount": 1, + "droppedPaths": ["error"], + "method": "gitlab.updateMR", + "operation": "gitlab.update-merge-request", + "variant": "task-item-mutation" + } + }, + "5a043e84e661": { + "error": "Unknown method", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "5c50471cdd48": { + "name": "mutatingStatus", + "ordinal": 10, + "value": false + }, + "6bb649f0ecab": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -575,7 +524,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -612,17 +564,17 @@ } } }, - "ac46e56e89fe": { + "6bb96c7332db": { "name": "error", "ordinal": 5, - "value": "Unknown method" + "value": "transport failure" }, - "b4055ff60394": { - "name": "itemAddLabelsDraft", - "ordinal": 8, - "value": "" + "776607d47471": { + "name": "error", + "ordinal": 5, + "value": "inner refused" }, - "cb0f27a85172": { + "8fe1c2b3570d": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -634,7 +586,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -660,85 +615,34 @@ "startedAt": 0, "settledAt": 0, "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } + "ok": false } } }, - "ce0c8b4affc0": { - "name": "gitlab.updateMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "body": { - "$rpc": "undefined" - }, - "removeLabels": { - "$rpc": "undefined" - }, - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" }, - "d03b2863c41e": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - }, - "items": [ + "9e163f6ab338": { + "name": "items", + "ordinal": 6, + "value": [ { "provider": "gitlab", "source": { "id": "gitlab:mr:7", "labels": ["bug", "triage"], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "title": "Renamed", @@ -746,52 +650,123 @@ }, "title": "Renamed" } + ] + }, + "ac46e56e89fe": { + "name": "error", + "ordinal": 5, + "value": "Unknown method" + }, + "ac63ccac048a": { + "name": "gitlab.updateMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "body": { + "$rpc": "undefined" + }, + "removeLabels": { + "$rpc": "undefined" + }, + "title": "Renamed" + } + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug", "triage"], - "pipelineJobs": [], - "provider": "gitlab" + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } } }, - "d6b74d32804d": { - "name": "detailPayload", - "ordinal": 7, - "value": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" + "b4055ff60394": { + "name": "itemAddLabelsDraft", + "ordinal": 8, + "value": "" + }, + "b5e289e8dcd1": { + "name": "gitlab.updateMR#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMR" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "addLabels": ["triage"], + "body": { + "$rpc": "undefined" + }, + "removeLabels": { + "$rpc": "undefined" + }, + "title": "Renamed" + } } - ], - "labels": ["bug", "triage"], - "pipelineJobs": [], - "provider": "gitlab" + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } } }, - "d6f17e3de7da": { - "error": "transport failure", + "b5f237e60c83": { + "error": "", "item": { "provider": "gitlab", "source": { "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -805,7 +780,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -830,7 +808,7 @@ "provider": "gitlab" } }, - "d98b736ab220": { + "bf8dd8808e26": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -842,7 +820,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -864,19 +845,155 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "ok": true - } + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, - "de008e50629f": { + "c566e36ed73d": { + "error": "inner refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d04422b812a2": { + "error": "The host sent a reply this app could not read (gitlab.updateMR)", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d6b74d32804d": { + "name": "detailPayload", + "ordinal": 7, + "value": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug", "triage"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "db0f3547ca00": { + "name": "gitlab.updateMR#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"},\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]}}}" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ebd61b303f9f": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -888,7 +1005,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -923,58 +1043,6 @@ } } }, - "ea3a1df3a54e": { - "name": "gitlab.updateMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "body": { - "$rpc": "undefined" - }, - "removeLabels": { - "$rpc": "undefined" - }, - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ecf9003e4ef1": { "name": "mutatingStatus", "ordinal": 6, @@ -985,54 +1053,64 @@ "ordinal": 2, "value": "" }, + "ef61a5798943": { + "error": "transport failure", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "f11c308f1979": { "name": "itemRemoveLabelsDraft", "ordinal": 9, "value": "" }, - "f639547d6c09": { - "name": "gitlab.updateMR#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMR" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "addLabels": ["triage"], - "body": { - "$rpc": "undefined" - }, - "removeLabels": { - "$rpc": "undefined" - }, - "title": "Renamed" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 30000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, "f73d2249e573": { "name": "error", "ordinal": 5, @@ -1055,18 +1133,18 @@ { "id": "tk-item-metadata-gitlab-mr.normal:update-gitlab-settled", "observation": { - "sender": ["d98b736ab220"], - "payloads": ["231f5648a833"], + "sender": ["1baaae5c9ed0"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "d03b2863c41e", + "state": "266686bfdeec", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", - "6b479af80b3b", - "510654109df9", + "2f03f52f6db7", + "9e163f6ab338", "d6b74d32804d", "b4055ff60394", "f11c308f1979", @@ -1077,44 +1155,44 @@ { "id": "tk-item-metadata-gitlab-mr.result-absent:update-gitlab-settled", "observation": { - "sender": ["f639547d6c09"], - "payloads": ["231f5648a833"], + "sender": ["b5e289e8dcd1"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "82de13ba46dd", + "state": "d04422b812a2", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f73d2249e573", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.result-null:update-gitlab-settled", "observation": { - "sender": ["ce0c8b4affc0"], - "payloads": ["231f5648a833"], + "sender": ["565d4ea436d2"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "82de13ba46dd", + "state": "d04422b812a2", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f73d2249e573", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.inner-ok-missing:update-gitlab-settled", "observation": { - "sender": ["8b75d0a6b159"], - "payloads": ["231f5648a833"], + "sender": ["20dac44c5345"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "d03b2863c41e", + "state": "266686bfdeec", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", - "6b479af80b3b", - "510654109df9", + "2f03f52f6db7", + "9e163f6ab338", "d6b74d32804d", "b4055ff60394", "f11c308f1979", @@ -1125,26 +1203,26 @@ { "id": "tk-item-metadata-gitlab-mr.inner-false-string-error:update-gitlab-settled", "observation": { - "sender": ["cb0f27a85172"], - "payloads": ["231f5648a833"], + "sender": ["46041f85f179"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "98354008c52b", + "state": "c566e36ed73d", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.inner-false-object-error:update-gitlab-settled", "observation": { - "sender": ["a6357bef2eec"], - "payloads": ["231f5648a833"], + "sender": ["6bb649f0ecab"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "438d6fb98dc1", + "state": "29c6b20b5026", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1157,65 +1235,65 @@ { "id": "tk-item-metadata-gitlab-mr.outer-refused:update-gitlab-settled", "observation": { - "sender": ["a1646d9e9194"], - "payloads": ["231f5648a833"], + "sender": ["57cef18396c5"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "702351d98030", + "state": "37c27ca402e4", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.outer-refused-no-message:update-gitlab-settled", "observation": { - "sender": ["de008e50629f"], - "payloads": ["231f5648a833"], + "sender": ["ebd61b303f9f"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "69841347ee06", + "state": "b5f237e60c83", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.method-not-found:update-gitlab-settled", "observation": { - "sender": ["59642c50afeb"], - "payloads": ["231f5648a833"], + "sender": ["8fe1c2b3570d"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "0687dba3171a", + "state": "5a043e84e661", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.transport-rejection:update-gitlab-settled", "observation": { - "sender": ["52952495ce9f"], - "payloads": ["231f5648a833"], + "sender": ["bf8dd8808e26"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "d6f17e3de7da", + "state": "ef61a5798943", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-metadata-gitlab-mr.transport-rejection-no-message:update-gitlab-settled", "observation": { - "sender": ["ea3a1df3a54e"], - "payloads": ["231f5648a833"], + "sender": ["ac63ccac048a"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "69841347ee06", + "state": "b5f237e60c83", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json index a3190dbd8f3..25ee4af935d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-reply-merge-github", "family": "tasks.item-reply-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "c6e5ae446b875afba3944a96d931fdca6006ed8e904374e5040088004eb9b044", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "029bb83f402f": { "name": "error", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json index 59f6fc744dc..0d52f9d15b4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-reply-merge-github", "family": "tasks.item-reply-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "2da11d5a9c7a223a59c56ec416ab33acd42d900b4080132bde27313e042808d9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "029bb83f402f": { "name": "error", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json index 7e924dee560..f67d7fd871b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-reply-merge-github", "family": "tasks.item-reply-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "a5d8ba37f44421d3efd19617ef319f37fda3fe53004b6829ae2c320f85d5c98d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f53bdc9dca": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json index aa1f2f09e61..83c9260dc07 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-reply-merge-github", "family": "tasks.item-reply-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "abc4b882c92ba90a52d3635fe882b2c653bc91ab4d9927f240ebee4dd147b81d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f53bdc9dca": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json index 8af1b976c2e..689e0d711b1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-review-github", "family": "tasks.item-review-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "77b0812842903075bb3d3ec1f7bcea94b1e1dac5993c3c8854bd4c3d2a567988", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0617d29f99ab": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json index 22163e9d2eb..16e2ac93cbe 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-review-github", "family": "tasks.item-review-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "96a1813de0159396f6a5eb36a764fad4511de0a25eff6323571e1fade2a7f334", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04df4241c3b7": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json index 3ddb6d68b53..9ac239ea1f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json @@ -1,34 +1,157 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-status-gitlab", "family": "tasks.item-status-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "57c41b51e34e451975a9f28a6461aa3b8e9dcf04cbebaea80ddf14afc4b78edf", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04df4241c3b7": { "name": "mutatingStatus", "ordinal": 7, "value": true }, + "05df5d49a11c": { + "error": "", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "104bf14d3af8": { "name": "error", "ordinal": 8, "value": "" }, + "14ef5ce6d8e3": { + "error": "transport failure", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "17366c313ad9": { "name": "error", "ordinal": 11, "value": "" }, - "18eece2e22ec": { + "20cf2c45839d": { + "name": "github.updateIssue#1", + "ordinal": 10, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" + }, + "20d4f63859b9": { + "name": "actionItem", + "ordinal": 11, + "value": { + "$rpc": "null" + } + }, + "23cccef54140": { + "error": "The host sent a reply this app could not read (github.updateIssue)", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "2452bd6b82d1": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -40,7 +163,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -67,102 +193,11 @@ } } }, - "20cf2c45839d": { - "name": "github.updateIssue#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" - }, - "20d4f63859b9": { - "name": "actionItem", - "ordinal": 11, - "value": { - "$rpc": "null" - } - }, - "214ca42de359": { - "error": "Unknown method", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "263d60f9991d": { - "name": "gitlab.updateIssue#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"state\":\"closed\"},\"projectRef\":\"group/project\"}}" - }, "29e0aa52e5c5": { "name": "error", "ordinal": 11, "value": "inner refused" }, - "2e4f742ab84f": { - "error": "", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": true, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "2ec6dd156983": { "name": "github.updateIssue#1", "ordinal": 9, @@ -318,7 +353,7 @@ "ordinal": 14, "value": "" }, - "4f7c0c0ed605": { + "6463243364b2": { "name": "items", "ordinal": 12, "value": [ @@ -328,7 +363,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -337,80 +375,6 @@ } ] }, - "51d9bbde2d12": { - "error": "inner refused", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "52b163f18d0b": { - "error": "transport failure", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "69d1fe10bcd0": { "name": "reply-salvage", "ordinal": 11, @@ -505,8 +469,13 @@ "ordinal": 11, "value": "transport failure" }, - "734a3e25cf8e": { - "error": "Failed to update GitHub issue", + "7c9ae1d5d0a9": { + "name": "gitlab.updateIssue#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"state\":\"closed\"},\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "82d05ab90043": { + "error": "inner refused", "item": { "$rpc": "null" }, @@ -517,7 +486,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -547,43 +519,6 @@ "ordinal": 17, "value": "" }, - "8803676e3004": { - "error": "outer refused", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "954ee7a340ac": { "name": "github.updateIssue#1", "ordinal": 9, @@ -729,43 +664,6 @@ "ordinal": 12, "value": "Failed to update GitHub issue" }, - "b1a762799e6a": { - "error": "The host sent a reply this app could not read (github.updateIssue)", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "b1bc11f4b7fa": { "name": "github.updateIssue#1", "ordinal": 9, @@ -804,18 +702,8 @@ } } }, - "bcc7aaa042a8": { - "name": "error", - "ordinal": 11, - "value": "The host sent a reply this app could not read (github.updateIssue)" - }, - "d94bd0f93daf": { - "name": "error", - "ordinal": 11, - "value": "Unknown method" - }, - "d9d32e421d46": { - "error": "", + "b952dc3c700b": { + "error": "outer refused", "item": { "$rpc": "null" }, @@ -826,7 +714,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -851,6 +742,96 @@ "provider": "gitlab" } }, + "bcc7aaa042a8": { + "name": "error", + "ordinal": 11, + "value": "The host sent a reply this app could not read (github.updateIssue)" + }, + "d4a41a8702b3": { + "error": "", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": true, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d71a382a16dc": { + "error": "Unknown method", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "d94bd0f93daf": { + "name": "error", + "ordinal": 11, + "value": "Unknown method" + }, "d9f85a74992e": { "name": "itemAddAssigneesDraft", "ordinal": 16, @@ -879,6 +860,46 @@ "ordinal": 2, "value": "" }, + "f6e307010028": { + "error": "Failed to update GitHub issue", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "fa0f8ddcd40a": { "name": "github.updateIssue#1", "ordinal": 9, @@ -1014,27 +1035,27 @@ { "id": "tk-item-status-gitlab.prelude:gitlab-status-settled", "observation": { - "sender": ["18eece2e22ec"], - "payloads": ["263d60f9991d"], + "sender": ["2452bd6b82d1"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.prelude:cleanup", "observation": { - "sender": ["18eece2e22ec", "2ef350f60efd"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "2ef350f60efd"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "2e4f742ab84f", + "state": "d4a41a8702b3", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1050,14 +1071,14 @@ { "id": "tk-item-status-gitlab.normal:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1066,7 +1087,7 @@ "04df4241c3b7", "104bf14d3af8", "20d4f63859b9", - "4f7c0c0ed605", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1079,14 +1100,14 @@ { "id": "tk-item-status-gitlab.result-absent:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "6bd067250087"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "6bd067250087"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "b1a762799e6a", + "state": "23cccef54140", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1102,14 +1123,14 @@ { "id": "tk-item-status-gitlab.result-null:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "fc36047d047e"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "fc36047d047e"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "b1a762799e6a", + "state": "23cccef54140", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1125,14 +1146,14 @@ { "id": "tk-item-status-gitlab.inner-ok-missing:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "377bd65db562"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "377bd65db562"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1141,7 +1162,7 @@ "04df4241c3b7", "104bf14d3af8", "20d4f63859b9", - "4f7c0c0ed605", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1154,14 +1175,14 @@ { "id": "tk-item-status-gitlab.inner-false-string-error:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "70fd63149003"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "70fd63149003"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "51d9bbde2d12", + "state": "82d05ab90043", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1177,14 +1198,14 @@ { "id": "tk-item-status-gitlab.inner-false-object-error:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "fa57439ae878"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "fa57439ae878"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "734a3e25cf8e", + "state": "f6e307010028", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1201,14 +1222,14 @@ { "id": "tk-item-status-gitlab.outer-refused:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "a5d01eaf8c2c"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "a5d01eaf8c2c"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "8803676e3004", + "state": "b952dc3c700b", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1224,14 +1245,14 @@ { "id": "tk-item-status-gitlab.outer-refused-no-message:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "9b69888173d6"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "9b69888173d6"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1247,14 +1268,14 @@ { "id": "tk-item-status-gitlab.method-not-found:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "2ec6dd156983"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "2ec6dd156983"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "214ca42de359", + "state": "d71a382a16dc", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1270,14 +1291,14 @@ { "id": "tk-item-status-gitlab.transport-rejection:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "b1bc11f4b7fa"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "b1bc11f4b7fa"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "52b163f18d0b", + "state": "14ef5ce6d8e3", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1293,14 +1314,14 @@ { "id": "tk-item-status-gitlab.transport-rejection-no-message:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "954ee7a340ac"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "954ee7a340ac"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json index 93102932341..186594c770d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json @@ -1,58 +1,9 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-status-gitlab", "family": "tasks.item-status-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "64a7af1bfac25dfe673832ef0ef7776be8d1a628c995eafd938fa3ad11d7ba0f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "02589051728b": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, "04df4241c3b7": { "name": "mutatingStatus", "ordinal": 7, @@ -63,52 +14,58 @@ "ordinal": 8, "value": true }, - "0932f1414f25": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ + "05df5d49a11c": { + "error": "", + "item": { + "$rpc": "null" + }, + "items": [ { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" } ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" } }, - "0a5028edd717": { - "error": "inner refused", + "0f541a5aff5d": { + "error": "Failed to update GitLab item", "item": { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -122,7 +79,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -157,63 +117,7 @@ "ordinal": 7, "value": false }, - "18eece2e22ec": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "ok": true - } - } - } - }, - "20cf2c45839d": { - "name": "github.updateIssue#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" - }, - "20d4f63859b9": { - "name": "actionItem", - "ordinal": 11, - "value": { - "$rpc": "null" - } - }, - "263d60f9991d": { - "name": "gitlab.updateIssue#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"state\":\"closed\"},\"projectRef\":\"group/project\"}}" - }, - "29c448841f73": { + "15de9730d149": { "error": "The host sent a reply this app could not read (gitlab.updateIssue)", "item": { "provider": "gitlab", @@ -221,7 +125,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -235,7 +142,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -260,12 +170,7 @@ "provider": "gitlab" } }, - "2a33d3e3468b": { - "name": "error", - "ordinal": 6, - "value": "Failed to update GitLab item" - }, - "30df3428a729": { + "1fe41e696460": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -277,7 +182,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -305,11 +213,216 @@ } } }, + "20cf2c45839d": { + "name": "github.updateIssue#1", + "ordinal": 10, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" + }, + "20d4f63859b9": { + "name": "actionItem", + "ordinal": 11, + "value": { + "$rpc": "null" + } + }, + "2452bd6b82d1": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "ok": true + } + } + } + }, + "2a33d3e3468b": { + "name": "error", + "ordinal": 6, + "value": "Failed to update GitLab item" + }, + "2f7b856c5232": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, "31052c726682": { "name": "itemRemoveLabelsDraft", "ordinal": 15, "value": "" }, + "39fb66a9e6ea": { + "error": "outer refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "3af68e0534be": { + "error": "inner refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "3b10c3bd94b0": { "name": "reply-salvage", "ordinal": 5, @@ -321,6 +434,49 @@ "variant": "task-item-mutation" } }, + "3cce0ad2ffd9": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, "3ea7248d382b": { "name": "actionItem", "ordinal": 5, @@ -387,19 +543,34 @@ "provider": "gitlab" } }, + "49b475d0b5ad": { + "name": "actionItem", + "ordinal": 12, + "value": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + }, "49e6ea49243a": { "name": "itemAddLabelsDraft", "ordinal": 14, "value": "" }, - "4becc20e0de4": { - "name": "itemRemoveAssigneesDraft", - "ordinal": 18, - "value": "" - }, - "4f7c0c0ed605": { + "4bbca4b48e51": { "name": "items", - "ordinal": 12, + "ordinal": 13, "value": [ { "provider": "gitlab", @@ -407,7 +578,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -416,7 +590,12 @@ } ] }, - "568afee05536": { + "4becc20e0de4": { + "name": "itemRemoveAssigneesDraft", + "ordinal": 18, + "value": "" + }, + "5fdc144272c0": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -428,7 +607,76 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "608c683e45b6": { + "name": "itemRemoveLabelsDraft", + "ordinal": 16, + "value": "" + }, + "6463243364b2": { + "name": "items", + "ordinal": 12, + "value": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ] + }, + "6a8414bc4055": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -450,20 +698,57 @@ "id": "frame-1", "ok": true, "result": { - "error": { - "message": "inner refused" - }, - "ok": false + "$rpc": "null" } } } }, - "608c683e45b6": { - "name": "itemRemoveLabelsDraft", - "ordinal": 16, + "6bb96c7332db": { + "name": "error", + "ordinal": 5, + "value": "transport failure" + }, + "6c731a45bff2": { + "name": "error", + "ordinal": 5, + "value": "The host sent a reply this app could not read (gitlab.updateIssue)" + }, + "776607d47471": { + "name": "error", + "ordinal": 5, + "value": "inner refused" + }, + "7c9ae1d5d0a9": { + "name": "gitlab.updateIssue#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"state\":\"closed\"},\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "832217477701": { + "name": "itemRemoveAssigneesDraft", + "ordinal": 17, "value": "" }, - "60cc870b4f0d": { + "8c56e68a9afe": { + "name": "actionItem", + "ordinal": 11, + "value": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + }, + "8cc363047957": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -475,7 +760,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -494,16 +782,21 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, "id": "frame-1", - "ok": false + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } } } }, - "61dac3bb3c2d": { + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" + }, + "92e28cad9d3f": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -515,7 +808,52 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "99ab04636572": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -543,196 +881,12 @@ } } }, - "6522c245c770": { - "name": "actionItem", - "ordinal": 12, - "value": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - }, - "660d75c1eef3": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "6bb96c7332db": { - "name": "error", - "ordinal": 5, - "value": "transport failure" - }, - "6c0a22c5980e": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "6c731a45bff2": { - "name": "error", - "ordinal": 5, - "value": "The host sent a reply this app could not read (gitlab.updateIssue)" - }, - "776607d47471": { - "name": "error", - "ordinal": 5, - "value": "inner refused" - }, - "832217477701": { - "name": "itemRemoveAssigneesDraft", - "ordinal": 17, - "value": "" - }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" - }, - "91a21ea94466": { - "name": "actionItem", - "ordinal": 11, - "value": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - }, "a17cf032d1cb": { "name": "mutatingStatus", "ordinal": 18, "value": false }, - "a3139ecf7ce9": { - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "a85803b27ae1": { + "a484e94ad6b4": { "error": "transport failure", "item": { "provider": "gitlab", @@ -740,7 +894,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -754,7 +911,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -789,99 +949,18 @@ "ordinal": 19, "value": false }, - "c02252fb214d": { - "error": "", + "c00088248e96": { + "error": "Unknown method", "item": { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" + "projectRef": { + "host": "gitlab.com", + "path": "group/project" }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "c406ace6b8d6": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "c555f8c27d97": { - "error": "Failed to update GitLab item", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", "repoId": "repo-1", "state": "opened", "type": "issue" @@ -895,7 +974,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -925,38 +1007,10 @@ "ordinal": 11, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" }, - "c7ff417f5a6d": { - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { + "cd20e1e3b2f5": { + "name": "detailPayload", + "ordinal": 14, + "value": { "assignees": [], "body": "body", "comments": [ @@ -972,65 +1026,44 @@ "provider": "gitlab" } }, - "c8ba01996b7d": { - "name": "items", - "ordinal": 13, - "value": [ + "d45458f553ea": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ { "provider": "gitlab", "source": { "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" }, "title": "A GitLab issue" } - ] - }, - "cbed8054d16f": { - "name": "gitlab.updateIssue#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateIssue" - }, - { - "name": "params", - "value": { - "number": 4, - "projectRef": "group/project", - "repo": "id:repo-1", - "updates": { - "state": "closed" - } - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, - "cd20e1e3b2f5": { - "name": "detailPayload", - "ordinal": 14, - "value": { + "mutating": false, + "payload": { "assignees": [], "body": "body", "comments": [ @@ -1051,48 +1084,56 @@ "ordinal": 17, "value": "" }, - "d9d32e421d46": { - "error": "", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "d9f85a74992e": { "name": "itemAddAssigneesDraft", "ordinal": 16, "value": "" }, + "dd44f5d2ac5b": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, "e492cb3deb38": { "name": "error", "ordinal": 9, @@ -1126,6 +1167,46 @@ "ordinal": 5, "value": "" }, + "f9ca9797f75a": { + "name": "gitlab.updateIssue#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateIssue" + }, + { + "name": "params", + "value": { + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "updates": { + "state": "closed" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, "fa0f8ddcd40a": { "name": "github.updateIssue#1", "ordinal": 9, @@ -1178,27 +1259,27 @@ { "id": "tk-item-status-gitlab.normal:gitlab-status-settled", "observation": { - "sender": ["18eece2e22ec"], - "payloads": ["263d60f9991d"], + "sender": ["2452bd6b82d1"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.normal:github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1207,7 +1288,7 @@ "04df4241c3b7", "104bf14d3af8", "20d4f63859b9", - "4f7c0c0ed605", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1220,27 +1301,27 @@ { "id": "tk-item-status-gitlab.result-absent:gitlab-status-settled", "observation": { - "sender": ["cbed8054d16f"], - "payloads": ["263d60f9991d"], + "sender": ["5fdc144272c0"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "29c448841f73", + "state": "15de9730d149", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6c731a45bff2", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.result-absent:github-metadata-settled", "observation": { - "sender": ["cbed8054d16f", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["5fdc144272c0", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1248,8 +1329,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1262,27 +1343,27 @@ { "id": "tk-item-status-gitlab.result-null:gitlab-status-settled", "observation": { - "sender": ["6c0a22c5980e"], - "payloads": ["263d60f9991d"], + "sender": ["6a8414bc4055"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "29c448841f73", + "state": "15de9730d149", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6c731a45bff2", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.result-null:github-metadata-settled", "observation": { - "sender": ["6c0a22c5980e", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["6a8414bc4055", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1290,8 +1371,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1304,27 +1385,27 @@ { "id": "tk-item-status-gitlab.inner-ok-missing:gitlab-status-settled", "observation": { - "sender": ["660d75c1eef3"], - "payloads": ["263d60f9991d"], + "sender": ["92e28cad9d3f"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.inner-ok-missing:github-metadata-settled", "observation": { - "sender": ["660d75c1eef3", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["92e28cad9d3f", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1333,7 +1414,7 @@ "04df4241c3b7", "104bf14d3af8", "20d4f63859b9", - "4f7c0c0ed605", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1346,27 +1427,27 @@ { "id": "tk-item-status-gitlab.inner-false-string-error:gitlab-status-settled", "observation": { - "sender": ["02589051728b"], - "payloads": ["263d60f9991d"], + "sender": ["8cc363047957"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "0a5028edd717", + "state": "3af68e0534be", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.inner-false-string-error:github-metadata-settled", "observation": { - "sender": ["02589051728b", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["8cc363047957", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1374,8 +1455,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1388,13 +1469,13 @@ { "id": "tk-item-status-gitlab.inner-false-object-error:gitlab-status-settled", "observation": { - "sender": ["568afee05536"], - "payloads": ["263d60f9991d"], + "sender": ["dd44f5d2ac5b"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "c555f8c27d97", + "state": "0f541a5aff5d", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1407,14 +1488,14 @@ { "id": "tk-item-status-gitlab.inner-false-object-error:github-metadata-settled", "observation": { - "sender": ["568afee05536", "411309460aee"], - "payloads": ["263d60f9991d", "c71182b6f8cf"], + "sender": ["dd44f5d2ac5b", "411309460aee"], + "payloads": ["7c9ae1d5d0a9", "c71182b6f8cf"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1423,8 +1504,8 @@ "14949c71727e", "04f53bdc9dca", "e492cb3deb38", - "6522c245c770", - "c8ba01996b7d", + "49b475d0b5ad", + "4bbca4b48e51", "cd20e1e3b2f5", "f538bcc5e962", "608c683e45b6", @@ -1437,27 +1518,27 @@ { "id": "tk-item-status-gitlab.outer-refused:gitlab-status-settled", "observation": { - "sender": ["30df3428a729"], - "payloads": ["263d60f9991d"], + "sender": ["1fe41e696460"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "c7ff417f5a6d", + "state": "39fb66a9e6ea", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.outer-refused:github-metadata-settled", "observation": { - "sender": ["30df3428a729", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["1fe41e696460", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1465,8 +1546,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1479,27 +1560,27 @@ { "id": "tk-item-status-gitlab.outer-refused-no-message:gitlab-status-settled", "observation": { - "sender": ["61dac3bb3c2d"], - "payloads": ["263d60f9991d"], + "sender": ["99ab04636572"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.outer-refused-no-message:github-metadata-settled", "observation": { - "sender": ["61dac3bb3c2d", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["99ab04636572", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1507,8 +1588,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1521,27 +1602,27 @@ { "id": "tk-item-status-gitlab.method-not-found:gitlab-status-settled", "observation": { - "sender": ["60cc870b4f0d"], - "payloads": ["263d60f9991d"], + "sender": ["3cce0ad2ffd9"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "a3139ecf7ce9", + "state": "c00088248e96", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.method-not-found:github-metadata-settled", "observation": { - "sender": ["60cc870b4f0d", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["3cce0ad2ffd9", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1549,8 +1630,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1563,27 +1644,27 @@ { "id": "tk-item-status-gitlab.transport-rejection:gitlab-status-settled", "observation": { - "sender": ["c406ace6b8d6"], - "payloads": ["263d60f9991d"], + "sender": ["f9ca9797f75a"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "a85803b27ae1", + "state": "a484e94ad6b4", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.transport-rejection:github-metadata-settled", "observation": { - "sender": ["c406ace6b8d6", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["f9ca9797f75a", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1591,8 +1672,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", @@ -1605,27 +1686,27 @@ { "id": "tk-item-status-gitlab.transport-rejection-no-message:gitlab-status-settled", "observation": { - "sender": ["0932f1414f25"], - "payloads": ["263d60f9991d"], + "sender": ["2f7b856c5232"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab.transport-rejection-no-message:github-metadata-settled", "observation": { - "sender": ["0932f1414f25", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2f7b856c5232", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "c02252fb214d", + "state": "d45458f553ea", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -1633,8 +1714,8 @@ "ecf9003e4ef1", "04df4241c3b7", "104bf14d3af8", - "91a21ea94466", - "4f7c0c0ed605", + "8c56e68a9afe", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json index ff43beb630c..8136622520c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json @@ -1,66 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-status-gitlab-mr", "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "8443a59a1d432fbcfb9d158995cfa69bef364c33b66a97cbef0e70e197fcad4d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "0687dba3171a": { - "error": "Unknown method", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "06c5c1e8eb75": { + "11b03059ccee": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -72,7 +16,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -92,153 +39,28 @@ "id": "frame-1", "ok": true, "result": { - "error": { - "message": "inner refused" - }, - "ok": false + "error": "refused" } } } }, - "12e61e17518f": { - "name": "gitlab.updateMRState#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMRState" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "state": "closed" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } - }, "14949c71727e": { "name": "mutatingStatus", "ordinal": 7, "value": false }, - "16e3963edac7": { - "name": "gitlab.updateMRState#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMRState" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "state": "closed" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "241a52d073fe": { - "name": "gitlab.updateMRState#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMRState" - }, - { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "state": "closed" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } - }, - "2a33d3e3468b": { - "name": "error", - "ordinal": 6, - "value": "Failed to update GitLab item" - }, - "3ea7248d382b": { - "name": "actionItem", - "ordinal": 5, - "value": { - "$rpc": "null" - } - }, - "438d6fb98dc1": { - "error": "Failed to update GitLab item", + "1a0b0007e5f8": { + "error": "The host sent a reply this app could not read (gitlab.updateMRState)", "item": { "provider": "gitlab", "source": { "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -252,7 +74,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -277,42 +102,60 @@ "provider": "gitlab" } }, - "45df43415fb6": { - "name": "gitlab.updateMRState#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "gitlab.updateMRState" + "29c6b20b5026": { + "error": "Failed to update GitLab item", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" }, + "title": "A merge request" + }, + "items": [ { - "name": "params", - "value": { - "iid": 7, - "projectRef": "group/project", - "repo": "id:repo-1", - "state": "closed" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" } ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" } }, - "5820eba93a29": { + "2a01c3ca724c": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -324,7 +167,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -350,7 +196,12 @@ } } }, - "67fe18edd7c0": { + "2a33d3e3468b": { + "name": "error", + "ordinal": 6, + "value": "Failed to update GitLab item" + }, + "2bc27449a25e": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -362,7 +213,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -380,23 +234,22 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } + "ok": true } } }, - "69841347ee06": { - "error": "", + "37c27ca402e4": { + "error": "outer refused", "item": { "provider": "gitlab", "source": { "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -410,7 +263,151 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "3ea7248d382b": { + "name": "actionItem", + "ordinal": 5, + "value": { + "$rpc": "null" + } + }, + "5343609af324": { + "name": "gitlab.updateMRState#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMRState" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "state": "closed" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "589373883844": { + "error": "", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "5a043e84e661": { + "error": "Unknown method", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -440,221 +437,7 @@ "ordinal": 5, "value": "transport failure" }, - "702351d98030": { - "error": "outer refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "776607d47471": { - "name": "error", - "ordinal": 5, - "value": "inner refused" - }, - "7c807865d5b3": { - "error": "The host sent a reply this app could not read (gitlab.updateMRState)", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "819e4dd57be3": { - "name": "error", - "ordinal": 5, - "value": "The host sent a reply this app could not read (gitlab.updateMRState)" - }, - "91259ae589b2": { - "name": "error", - "ordinal": 5, - "value": "outer refused" - }, - "98354008c52b": { - "error": "inner refused", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "9906fdf9fbf3": { - "name": "reply-salvage", - "ordinal": 5, - "value": { - "droppedCount": 1, - "droppedPaths": ["error"], - "method": "gitlab.updateMRState", - "operation": "gitlab.update-merge-request-state", - "variant": "task-item-mutation" - } - }, - "a768a7cc2f33": { - "name": "gitlab.updateMRState#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMRState\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"state\":\"closed\",\"projectRef\":\"group/project\"}}" - }, - "ac46e56e89fe": { - "name": "error", - "ordinal": 5, - "value": "Unknown method" - }, - "b6a6630b4d40": { - "error": "", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "bc79956b041e": { + "6f38f0858cf7": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -666,7 +449,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -686,12 +472,30 @@ "id": "frame-1", "ok": true, "result": { - "error": "refused" + "error": { + "message": "inner refused" + }, + "ok": false } } } }, - "cdcd712f5f4d": { + "776607d47471": { + "name": "error", + "ordinal": 5, + "value": "inner refused" + }, + "7c485a70bc3e": { + "name": "gitlab.updateMRState#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMRState\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"state\":\"closed\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "819e4dd57be3": { + "name": "error", + "ordinal": 5, + "value": "The host sent a reply this app could not read (gitlab.updateMRState)" + }, + "853d43d62b51": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -703,7 +507,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -716,63 +523,33 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, - "d6f17e3de7da": { - "error": "transport failure", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" + "91259ae589b2": { + "name": "error", + "ordinal": 5, + "value": "outer refused" + }, + "9906fdf9fbf3": { + "name": "reply-salvage", + "ordinal": 5, + "value": { + "droppedCount": 1, + "droppedPaths": ["error"], + "method": "gitlab.updateMRState", + "operation": "gitlab.update-merge-request-state", + "variant": "task-item-mutation" } }, - "da71c86a0c81": { + "ab2e27397394": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -784,7 +561,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -809,35 +589,65 @@ } } }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "ecf9003e4ef1": { - "name": "mutatingStatus", - "ordinal": 6, - "value": false - }, - "ed3a7d6bc894": { - "name": "error", - "ordinal": 2, - "value": "" - }, - "f8df20507017": { + "ac46e56e89fe": { "name": "error", "ordinal": 5, - "value": "" + "value": "Unknown method" }, - "fd2fad47bec2": { - "name": "mutatingStatus", - "ordinal": 1, - "value": true + "b5f237e60c83": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } }, - "ff2ea7d409b7": { + "b6784a8212c5": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -849,7 +659,144 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "state": "closed" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "baef924cd2e3": { + "name": "gitlab.updateMRState#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMRState" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "state": "closed" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "c566e36ed73d": { + "error": "inner refused", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "db6974ea440e": { + "name": "gitlab.updateMRState#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMRState" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -871,6 +818,128 @@ "isRpcDeliveryUnknown": true } } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ecf62b9a0bef": { + "name": "gitlab.updateMRState#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "gitlab.updateMRState" + }, + { + "name": "params", + "value": { + "iid": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repo": "id:repo-1", + "state": "closed" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "ecf9003e4ef1": { + "name": "mutatingStatus", + "ordinal": 6, + "value": false + }, + "ed3a7d6bc894": { + "name": "error", + "ordinal": 2, + "value": "" + }, + "ef61a5798943": { + "error": "transport failure", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "f8df20507017": { + "name": "error", + "ordinal": 5, + "value": "" + }, + "fd2fad47bec2": { + "name": "mutatingStatus", + "ordinal": 1, + "value": true } }, "recording": { @@ -879,78 +948,78 @@ { "id": "tk-item-status-gitlab-mr.normal:gitlab-status-settled", "observation": { - "sender": ["da71c86a0c81"], - "payloads": ["a768a7cc2f33"], + "sender": ["ab2e27397394"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "b6a6630b4d40", + "state": "589373883844", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.result-absent:gitlab-status-settled", "observation": { - "sender": ["cdcd712f5f4d"], - "payloads": ["a768a7cc2f33"], + "sender": ["2bc27449a25e"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "7c807865d5b3", + "state": "1a0b0007e5f8", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "819e4dd57be3", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.result-null:gitlab-status-settled", "observation": { - "sender": ["16e3963edac7"], - "payloads": ["a768a7cc2f33"], + "sender": ["baef924cd2e3"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "7c807865d5b3", + "state": "1a0b0007e5f8", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "819e4dd57be3", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.inner-ok-missing:gitlab-status-settled", "observation": { - "sender": ["bc79956b041e"], - "payloads": ["a768a7cc2f33"], + "sender": ["11b03059ccee"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "b6a6630b4d40", + "state": "589373883844", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.inner-false-string-error:gitlab-status-settled", "observation": { - "sender": ["67fe18edd7c0"], - "payloads": ["a768a7cc2f33"], + "sender": ["b6784a8212c5"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "98354008c52b", + "state": "c566e36ed73d", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "776607d47471", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.inner-false-object-error:gitlab-status-settled", "observation": { - "sender": ["06c5c1e8eb75"], - "payloads": ["a768a7cc2f33"], + "sender": ["6f38f0858cf7"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "438d6fb98dc1", + "state": "29c6b20b5026", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -963,65 +1032,65 @@ { "id": "tk-item-status-gitlab-mr.outer-refused:gitlab-status-settled", "observation": { - "sender": ["12e61e17518f"], - "payloads": ["a768a7cc2f33"], + "sender": ["5343609af324"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "702351d98030", + "state": "37c27ca402e4", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "91259ae589b2", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.outer-refused-no-message:gitlab-status-settled", "observation": { - "sender": ["5820eba93a29"], - "payloads": ["a768a7cc2f33"], + "sender": ["2a01c3ca724c"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "69841347ee06", + "state": "b5f237e60c83", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.method-not-found:gitlab-status-settled", "observation": { - "sender": ["241a52d073fe"], - "payloads": ["a768a7cc2f33"], + "sender": ["ecf62b9a0bef"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "0687dba3171a", + "state": "5a043e84e661", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "ac46e56e89fe", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.transport-rejection:gitlab-status-settled", "observation": { - "sender": ["45df43415fb6"], - "payloads": ["a768a7cc2f33"], + "sender": ["853d43d62b51"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "d6f17e3de7da", + "state": "ef61a5798943", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "6bb96c7332db", "ecf9003e4ef1"] } }, { "id": "tk-item-status-gitlab-mr.transport-rejection-no-message:gitlab-status-settled", "observation": { - "sender": ["ff2ea7d409b7"], - "payloads": ["a768a7cc2f33"], + "sender": ["db6974ea440e"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "69841347ee06", + "state": "b5f237e60c83", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "f8df20507017", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json index 58deb7a6c84..9836c9955be 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-connect", "family": "tasks.linear-connect", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "a60d7c0ce3d155116aecbb3d1ca015b4d9de310155f4e840b2fc391dc9d04860", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0d5ee6814ade": { "name": "linearConnectState", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json index 79dc781dee0..49f1e9dba9a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-item-actions", "family": "tasks.linear-item", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "a2c0c5c200b36d2194815e67df2ea50422f6ecfd9df411132202e9660c87c41b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02bf1f0d7114": { "error": "", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json index e4e48b2582c..56a78d51bb4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-item-actions", "family": "tasks.linear-item", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "8a5c5b210459d3938bc010bc88c69696a631072cd5a6b7e1a3eb3e1fd0da9c8a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f53bdc9dca": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json index 21bd3a72283..c0aba956bf4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-item-actions", "family": "tasks.linear-item", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "ab0ac02611d487a9edd58319c6e4b9302148684f27711afc3b69feaa73fc4b07", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f53bdc9dca": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json index 5728d801bfd..a425e633842 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-team-context", "family": "tasks.linear-team-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", - "scenarioSha256": "c4a5928ef7035ad8bed20945f235f4fa509238b3a167c91df50baefff8e8433f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "045de1beb41b": { "name": "prFileCommentDrafts", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json index b466b490213..046947a019c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-team-context", "family": "tasks.linear-team-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", - "scenarioSha256": "8ec517c98775f3af0d45776dc74bcaaf99dcc751e85343084e39c557b6ddede1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "043bc3b21bd9": { "name": "linear.teamStates#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json index 810768d84b2..e67998d4875 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "77b429e1066a877bb3c8513e89e42314bb974478385695ceb36dbaa7c57e654a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01ebbe1cc2d6": { "name": "github.repoSlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json index 400112c00cb..bd24ac2dbc7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "8d58d134262cd828348bc3416f7208b51259dff9ea952eeceaab6e6dcfcfd96a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "088b54714e03": { "by-number": { diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json index 11d0eab7074..3e436562f5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "4dc2c4cfc9c29ee998796980a7ae6ded99a39f17e143e3db588239bc224b2cc5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "015cb9515a65": { "by-number": { diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json index 427f4b93c04..2c3e66f71e6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "d622fc252f4e2e1d2e244d8781b260ed6e42dee572b8d2fd39d6c3b49baae04a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0674893a13b7": { "name": "gitlab.workItemByPath#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json index 26c3fe0e2cf..1d4cbac096d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-board-load", "family": "tasks.project-board-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "7c16d49ffeace5c689309316da6e4638fa1b70d3ef52a78d27db934ae56cf64d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00ef2dbe5026": { "name": "github.project.resolveRef#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json index 9747f14435c..983cae9af5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-board-load", "family": "tasks.project-board-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "c5cc9a11a75a86780195be7d1055d1064c8aba78bb8e4e8bbdf033409c2b2aa5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056704adc0b": { "name": "githubProjectTable", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json index 049aa7f2a32..df7cdf99d9f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-board-load", "family": "tasks.project-board-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "7f748bb55df907bba315ea6899c585837d97fa0ea9b312330cf35d95bda87bd1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00ef2dbe5026": { "name": "github.project.resolveRef#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json index 6e56719868f..58cde3365b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-board-load", "family": "tasks.project-board-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "4ab4e4022cafd69cbc7af45ddf72d2dad3e8215df363511651d7faea014147d3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00ef2dbe5026": { "name": "github.project.resolveRef#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json index 75a2aab11dc..d70e7f92067 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-board-load", "family": "tasks.project-board-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "cf64dd43e708ff953ba2cbb2a70378aa8ab5d13ede157af7ba0967d913755b82", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056704adc0b": { "name": "githubProjectTable", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json index 869b299858c..197c556f4ad 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-repo-slugs", "family": "tasks.project-repo-slugs", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "667a06cf9ff8129eee64327566012448abe1fe31f58c0d9b882b13e74db5a877", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04de791c7267": { "name": "github.repoSlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json index 2a8b844acb0..7929e93de8d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-comments-issue", "family": "tasks.project-row-comments-issue", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "9fd4756c2224f3ecbc9ffc82e1ee11615a9e057e600bc56ccaf5c5e2c0e411d8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9f766ecc2": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json index 64bff9f06bb..be0ed08cb6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-comments-issue", "family": "tasks.project-row-comments-issue", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "cfe0284f502001a57a9d18585222e5ae4b254d9bac32e0bb4080ee9363e8e019", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "067ebe5d9da8": { "name": "github.project.updateIssueBySlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json index eed0fd8f95e..03136068ce5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-comments-issue", "family": "tasks.project-row-comments-issue", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "b21a3b3f568bf6caecca62272f4b7882062027684c70878fd5f883622ff2a9ea", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0077c5f71b31": { "name": "github.project.updateIssueCommentBySlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json index 2683eefce2c..233be2f7826 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-comments-pr", "family": "tasks.project-row-comments-pr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "e286edb942c338fd844ce8437ac03c872838228515ce833cfd310332354da725", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11ca5361468e": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json index 82a9c965719..95ededd494a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-detail", "family": "tasks.project-row-detail", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", - "scenarioSha256": "52b2cc222d9819121ba99f8f603bc76abe420d731e5853116e802a4db59e2d3e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0216abb9c43b": { "name": "projectTitleDraft", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json index 4720e2300ef..4c5ae086f7e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-fields", "family": "tasks.project-row-fields", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "0fe8c60631cf04d33b34371155f5f80c8426b07b1cbec2fabc5d6d619f2633b8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9f766ecc2": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json index d20f5e2f7ef..a580b541c91 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-fields", "family": "tasks.project-row-fields", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "8ad021e101d8ef17ed47472a5fbfbeaebb2690a4040c89a0e9ee133e69e4fecd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06f1fbeb0d68": { "error": "inner refused", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json index f44b7d8524d..3384b12d7a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-fields", "family": "tasks.project-row-fields", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "4d33ce12f3f35bea8a98fec2c0378e73caf7fbfb93085545e890b082a392cd60", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "015cfc810cb3": { "name": "projectFieldDrafts", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json index 1dca0b1ce70..18c320d76af 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-files-merge", "family": "tasks.project-row-files-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", - "scenarioSha256": "3ed626e9d776e009f008c85f5165c8199bc2b85afd279a435e2e0f800737b42f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0dc3824edb1e": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json index a45018204e3..a38ee656656 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-files-merge", "family": "tasks.project-row-files-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", - "scenarioSha256": "1e402d7984c7958ba23fb700464a450374ad72424be8f2669f769199a1a2899e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02446f5d4369": { "name": "github.mergePR#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json index fcc8971a6a8..eb678b28df0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-files-merge", "family": "tasks.project-row-files-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", - "scenarioSha256": "1b2ead3380b546c94c5da2ed61c9208cd158db9a56ca87e3e72e450668e1e507", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "07fd51a9e454": { "name": "github.prFileContents#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json index 1d277c901f8..7d9778b6a8d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-files-merge", "family": "tasks.project-row-files-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", - "scenarioSha256": "947b1c1aa9688688a1e4cbdbacfde185fbf5d0b28b13e753ba87bdf7f999aa00", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0dc3824edb1e": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json index 100f1dc72ff..bc397d45f74 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-files-merge", "family": "tasks.project-row-files-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", - "scenarioSha256": "cc450fcf20e403eea6ce1ee103c131feccb2906c0ae31c7cd3f3d3fa0b739d68", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0b6ed3c190bf": { "name": "github.updatePRState#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json index dcec771e5a3..497c43d6068 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-metadata-load", "family": "tasks.project-row-metadata-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", - "scenarioSha256": "2303fc902e5a6cf6a43db58ef2938538a7b770d1509cc22a9811f4c83051aea1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "012f4b9b6415": { "name": "github.project.listAssignableUsersBySlug#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json index 2924a6e6c71..0afc64c2adf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-metadata-load", "family": "tasks.project-row-metadata-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", - "scenarioSha256": "8b75854487566ca6da98d391b6822c81c99e61011fc094458ae038905c1c9287", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bf9ac2a3d0a": { "name": "projectIssueTypesError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json index 6a14216104f..85d5e8e7ec3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-metadata-load", "family": "tasks.project-row-metadata-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", - "scenarioSha256": "7c948e76afb331e4038298215181adc97cd533c4f79e205e183c08e8a2db20fc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "073745984248": { "name": "reply-salvage", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json index 1da8e7e377b..41b4cd986cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-review-checks", "family": "tasks.project-row-review-checks", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "20e9631df87e40e64d35cee0c0e06b922fe53bd93e3d9e4078633b85a5278b7e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0af1de3f1c7b": { "name": "github.setPRFileViewed#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json index 0b8309956a0..e56307a9c20 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-review-checks", "family": "tasks.project-row-review-checks", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "2d947b63fb35dad9bbe201061cb51d0b3c5a5e6f3e8018eeda2ec1f962952809", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0827e8a64c0d": { "detail": { diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json index 3f3b07d7f99..61b113200f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-review-checks", "family": "tasks.project-row-review-checks", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "ca6b0d81b19811806ce332a776361a40e526a73bef90cfa3df05a764e2ee83b6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "058c73abcfe0": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json index c81dbc72713..73eede6a6f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-review-checks", "family": "tasks.project-row-review-checks", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "bd58d88e0534366e281869ee79eb75fe65b418d02523d815d8d0d58799edc31b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04a1bdf42024": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json index 743274c20be..8be5635bf93 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-threads", "family": "tasks.project-row-threads", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "0f220b97bbb64ef8d347973690e6fab4b305eaaa54c9b7883340415c54e61206", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04a1bdf42024": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json index 244c36f18be..3cbe2ac1336 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-threads", "family": "tasks.project-row-threads", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "85160836c5a5c9bae76aff82c834ceb908f9262a90facd6e055c289362664eaa", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0063efc2d666": { "detail": { diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json index 8ce3d502001..877313a9636 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-threads", "family": "tasks.project-row-threads", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "5bc59cfd0d951ae5193df5c49ce3618d9536be0bc8f8192a7b03000963c2001b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09c572b1820f": { "name": "github.addPRReviewCommentReply#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json index dc1517e6e32..56bcd3cff99 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-threads", "family": "tasks.project-row-threads", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "0d8c240718b3911464a6fc486114d67cd6f60aa295cd0886ff755b77d5036014", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0063efc2d666": { "detail": { diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index 02beb58f0f6..644e3af522c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.provider-load", "family": "tasks.provider-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "e9bdad78cf60e3dd931eab8810d011c6e0066f9a336a206f8f9ca62621b21fee", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03738b1bd64d": { "name": "github.countWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index 8831bfb8fe8..257df4fd7ba 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.provider-load", "family": "tasks.provider-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "a5704c6849de9a45564c8738076ec8c0307754a5acc2c039880fe436771e68b3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "084da53ea1e3": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index 307bc0427cb..4584b4ad8ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.provider-load", "family": "tasks.provider-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "e518040cbd40e3cc8c22e0b70b6de1087386936f75ed2f65f6e7b4fcdc344ba2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04b4b89cebf6": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index 1e59f4bf470..48ace6c7daf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.provider-load", "family": "tasks.provider-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "f02acd2ed6319b8cb674c04b41a1e96c50e53123a8914790acc8af5410d63f98", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "009043ac6f6a": { "name": "settings.update#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index bc6016bb0f9..3d426ac6f02 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.provider-load", "family": "tasks.provider-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "2b3006bfe1e7f3040b86ccc698e58ae19ae1aa11389aee2ec2a8f2dfdb0270f4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "084da53ea1e3": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json index 1af1d818b40..adc7fb1616a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.route-repo-list", "family": "tasks.route-repo-list", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", - "scenarioSha256": "6373b83783b1a9b061bede3bba7aa3b573c3a50b897102a094df93f926856fdc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12e70439f294": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json index 8eed4e13824..2fc1fc097d9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "85c9e5107fe3e74f44d8b3e5c791b80ff0370cdc3ada69dc09fe38d8058fb263", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "005749a0bd75": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json index dbaf0bfb521..891da85abaa 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "0db7a8e1e5e740c341167bf045a0ab858f61708e995777fa4cf2e88cff1ec727", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "005749a0bd75": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json index 3b2700086f9..43d699399f4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "5d9211140d751c6cc844775d3e818f125483f1dac5b78960557ccfe73f41d08f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "005749a0bd75": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json index 16af661d49c..7410f762b25 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "1cfdbdeca951545a4fafd23649991e93a02feea72d174a7c469f18dc64decfc4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "005749a0bd75": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json index b73d10a73ce..2f3ef81da83 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "9fc0825ec19c2c90cec44ee284e7c54399e1552bdb15c329727921a15ad62944", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "005749a0bd75": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json index a6d36c306f8..284b8d3b534 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-github", "family": "tasks.task-create-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "014040846f649f3c6ca1175b610ed1b51bae3001e7de103549d7ee1a511a2506", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0070615b4c8b": { "name": "github.createIssue#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json index ff972eb7449..021b99c03b3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-github", "family": "tasks.task-create-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "9efe74e5a1b0d92f674e6044edfd534693c15b0a153bcb2c0e283a6cd53fa61b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fef9dc7845d": { "name": "createTitle", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json index 35df73656d4..5a2d1ed0fec 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-gitlab", "family": "tasks.task-create-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "217a3da50a54c4cbe19a68835ecfa038eae5b350430f0b8fd0a616bb5bdfe32d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a0e271f6e55": { "composer": true, diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json index d50335aecc3..77aef1820ea 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-linear", "family": "tasks.task-create-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "cb4c10cc6401e5b68340bd1fa09381c973348b6e0c7ffef563cde92080c57a15", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fef9dc7845d": { "name": "createTitle", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json index cb10520c34b..8a02b6d54b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-gitlab-items", "family": "tasks.task-list-gitlab-items", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "038221907a57f5bb25338f9b07a744dbac3ea0a7ea2ba43281f171912f45a586", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "18c09c3772f4": { "name": "gitlab.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json index ca43c4fd653..bc921035066 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-gitlab-todos", "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "e36b90621e11880ec1e6c16e8e3c89cc14f5d68a4a9e06b8a3098fc0bd0104a2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "038bb64a38ce": { "name": "gitlab.todos#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json index af56926182d..c2471a0f599 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-linear", "family": "tasks.task-list-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "39ef28af17e4d3774b42bba1555606770667bc9010692fb1a4492413a940c0a0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01588d77af7e": { "name": "linear.listIssues#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json index 75aeb3a962c..c2a6cf2d71d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-linear", "family": "tasks.task-list-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "c589700af145f58c37292dafb891b3b7d50302fd4ec044155c044ccd48f79c74", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "104bf14d3af8": { "name": "error", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json index b4254656d45..fc49ed0fab6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-source", "family": "tasks.workspace-source", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "9e91d46870cd69279cc7d8ebfd317ab8b13136ccff9662876c7601a3a83ecafe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02e24a49522e": { "name": "repo.sparsePresets#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json index 361b70033cd..f2d5ac0dcff 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-source", "family": "tasks.workspace-source", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "2d4a681bffbc5ff9d3040ea0d6bb2603ee940c3c497269d0b63caca564fb25e1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "003710284289": { "name": "repo.sparsePresets#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json index 53479f698f1..17d0e99d05d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-sparse", "family": "tasks.workspace-sparse", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "3d4637406e2d658b72f73153f0a5e176cb8b143593b72bce0cdf979bc6e4cbdd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02daf4600c50": { "name": "repo.saveSparsePreset#1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json index 2b77965536d..7b77a142c5f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-sparse", "family": "tasks.workspace-sparse", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "3453024581230908d1e0e9335f5310e7b4ae03faf50d93654b9ff28c464fb95f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "003266639675": { "name": "workspaceSshState", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index 9af3002abb7..a7297c87178 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh-local", "family": "tasks.workspace-ssh-local", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "28f7ba289c188bfc121ef7b969133711189e887fbfc7b37c8a5401ea7f30b56a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11405363c62d": { "name": "workspaceAgent", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json index cab57378ab5..b525386ffc4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh", "family": "tasks.workspace-ssh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "35771ab92d0d4ff44a1dd6e5f1e5d3137570a2e013bddea5ca77ecac7989ed53", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ba2cee4b538": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json index b05d49d4a86..858a4d195f3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh", "family": "tasks.workspace-ssh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "39451d3c811068754f91ac243fe1208f4ce742df53261314345d8209ba761e94", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06f3dbe600c4": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json index 337f7141d89..9f7074ccaaa 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh", "family": "tasks.workspace-ssh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "5cc1773d06d49d2616da72f2790753322edda6d67e12b5e41116971d34787391", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ba2cee4b538": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json index 59e1a75e76e..73f31b27030 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.query-reply", "family": "terminal.query-reply", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "15b5694a63d54aeb4f4d9a861729e7e3b42ce06b15af6784fcb1afab744ed18b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0420904bea73": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json index 9a2cc35f7b3..0f60ab1c0da 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.accessory-raw-send", "family": "terminal.raw-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "68c039f74da08523d67d8d5b0a178c1d12507ec88d25891cd1377cd3fb40205c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11a49f853eb8": { "accepted": true diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json index 160e170c605..da0fbcf9339 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.accessory-raw-send", "family": "terminal.raw-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "19d55a77e9c2f64f062070c9985f756e0ba72f3ccc3c884a80843fe888f42a47", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11a49f853eb8": { "accepted": true diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json index 6754149bda7..78c20de2835 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.takeover-report", "family": "terminal.takeover-report", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "6913de3f553b47a7e6663e21b0b93e97df26405c210db876f0b9593bd38936be", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "28970670143c": { "name": "orchestration.workerTerminalUserInput#1", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json index 456a924c62d..9a5377364e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.takeover-report", "family": "terminal.takeover-report", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "da13ec556c0f672371a8cd2aabd4dcc68001c0f9aa47015dfa4b5937355c4c2c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "060657937d18": { "name": "orchestration.workerTerminalUserInput#2", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json index 3cf51bfd2c9..6f57af10eee 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.viewport-refit", "family": "terminal.viewport-refit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "bde05ca916393d7f5ccd48686cf13a52fb2dfa599ea874b084c58bd59adb7e7f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00f9f7ee6897": { "name": "terminal.updateViewport#1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json index d8477f733f8..d9145977fcb 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.capability-probe", "family": "transport.capability-probe", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "874b1a120443ee679e2f4b3974762fd84fc929e93a2117b20bbb0cf373316616", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0474a1603b70": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json index f8477b7cd30..f8eaf5c6e64 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.host-status-gates", "family": "transport.host-status-gates", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "d6be62e5eb2737d75634c053098d06a4bb175c64ff915cec6ead79922492a068", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0474a1603b70": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json index cfcc4ff6da3..758a2eb9a21 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.pairing-race", "family": "transport.pairing-race", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "1d032c86e7cc12efa3d5044339cb990bd258d830119d0e7b61d1b995b4df29a3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0474a1603b70": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json index e7cc088517f..9f36c270ddf 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.pairing-race", "family": "transport.pairing-race", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "048ee6d7848e0e4b8d6463ff9dc4124bfd478114ab87f6b52ff82a1dfd6ebc04", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e93084d1837": { "name": "status.get#2", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json index 9a804d66f48..50b07165d95 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.agent-launch-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "b3d017cee84fc133ab0b0a8d588750f8af907d4688e6c2900a307de25fab8473", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12ace8a26229": { "outcome": { diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json index 4526333c823..74a0a1f658d 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.catalog-snapshot", "family": "worktree.catalog-snapshot", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "95ca47f382997c412da974e564a46b1ae0c20d6e0f3ca14258d33c8d8b51a160", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04ab3c489b82": { "name": "worktree.ps#1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index 286c7314ef1..d33aea5fac6 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "e93a36ef900de27e1a566cdb2389ba4f900a330eadbb476b9bfb1ef05707b352", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index aae6c3f6d6f..ddf664f22a1 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.home-catalog", "family": "worktree.home-catalog", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "fa0e28a167a5fba6fe7ffebb9f4ad28dd413d601c07116a24a7781d156f54beb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0809e2178d5a": { "name": "info", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json index e2021615a4a..82575576277 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.composer-hosted-base", "family": "worktree.hosted-base", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "a84f8a5acfd428eb77b5c02a3de0fa8b780c666db31bbe574ecf76cdf84adeb2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00d9cbb6c946": { "name": "worktree.resolveMrBase#1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json index 2c454d91e5e..892299943b6 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.composer-hosted-base", "family": "worktree.hosted-base", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "45783a7cbb44b04dbbd6bfd6735799bb4c75e503f43f1821cf8640d11f7464ad", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "002fc122fadf": { "name": "worktree.resolvePrBase#1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json index 069ab19c7e4..663f5ae054e 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.retired-names", "family": "worktree.retired-names", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "d321d6c17e67ae90f6ceefb775495ff765a86a35423ed33a212e71ec5e9e94aa", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3a6762b141aa": { "name": "worktree.listRetiredNames#1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index a2038eb0584..f4929931ebd 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.pr-link", "family": "worktree.review-link", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "2fc093ec505bfac04a4ff0adab991baeba985253486dbe9e3ec9884b8d5f0920", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0d39ad71ac82": { "linkedPR": "unread", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index ab22f058cd5..236aa175691 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-capabilities", "family": "worktree.runtime-capabilities", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "f70c6b1753377b5a502bf7d1e69dc95617f24c42137471320eb393567efbe735", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "041e5e32563c": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json index fb04070a8f9..bf43a3342a9 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.setup-hook-trust", "family": "worktree.setup-hook-trust", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "8487fd14ed779708415b264e2b80b26b0d5094e379a04f4f32c2cd75ec469182", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e645f49b2ec": { "name": "ui.set#1", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json index 019d9b15aad..ecd4142e522 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "9bab59797b04304d8e25573664b011949e9b15010270ccbfd0e3d2a4d772180c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fed70367a30": { "name": "mobileWeb.bundle.chunk#3", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json index c84cabb898b..f3134e87344 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "e3044fe513a5fc1fb10c5c00422d965dd760eeea37cfcebc79b4597141f4baa1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ec8dc44128d": { "name": "mobileWeb.bundle.chunk#2", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json index 53e2810822b..40a4769d629 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-manifest", "family": "mobileWeb.bundle-manifest", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "5e9f4e6878be3f534288a90f7ccabc9cfe4e968aa29da372a98c3064d8e4d89e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "23871e324a00": { "name": "mobileWeb.bundle.manifest#1", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json index ec443d06254..6c5510181dc 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "mobileWeb.bundle-fetch", "family": "mobileWeb.bundle-fetch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", - "scenarioSha256": "7333b1c83120bcfd9bdac8888ec1528c1db9b36325509f1328a551676df219c5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "6858fdcae414": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json index 5750aa66cf6..c9a75156464 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-paste", "family": "nativeChat.image-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "8e88e44d077ce8f3f3e3214b80e9e8b1e770b9a73025bc284a8333d6a8be07f9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json index dc80fd6650e..78350013d53 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-paste", "family": "nativeChat.image-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "1e2c7d2c238bb87a0fd1896a0151a6cecd670c9b1b2679ca47b39520355c6e05", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json index fc45d428da4..90ec8d78e1b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-paste", "family": "nativeChat.image-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "43575aede8307947e027239b4e582df281f6424c9216135ae20d150234f247f6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json index 044c0be5332..b11d5b53110 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-paste", "family": "nativeChat.image-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "4ff80434eea90f31c03522c9277983cc1c88d0292ba7c384fa9760fc1dcb03bf", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json index 94da28329da..d6aa4db899e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-upload", "family": "nativeChat.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "1f513883eb62cdd1673eb58809817e2b2f5fd64b74f80ed6e3b9d8c2ef2d33e9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "25716369cd8f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json index 9aa0b7cea3e..f09b32c7e78 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-upload", "family": "nativeChat.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "181b02243b1ecf98364473ee0b3f4c82a6037b5f5bae33703ab4f611cc050db4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2ec71b490683": { "name": "image-uploaded", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json index 1f339ad76fa..32c13ce5d33 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-upload", "family": "nativeChat.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "d7dca3742c4086f9ced0ba4b2f6c32ee9fa9272a5a955e8623fdc9cdb4c71528", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5c7ed03288e6": { "failure": { diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json index 95586c30d05..4c23ed09fc1 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-upload", "family": "nativeChat.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "09fd9bf97a4da7313e24f8c333b66c7c1af927bbea0a6d9fef9803856a608c78", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3c402c915494": { "name": "clipboard.startImageUpload#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json index bf8084aa951..8d7643ed0e1 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.image-upload", "family": "nativeChat.image-upload", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", - "scenarioSha256": "db0397f8f6ae28de0cc7afd91552ee9416d7f7054a76a42aac02f480c6f363d5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "147a6d6af2f1": { "name": "image-uploaded", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index 8eaf7296068..e77f69f1b73 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-page", "family": "session.native-chat-page", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", - "scenarioSha256": "674cab85ed9896dae311bfc0acfb1d1d1a2a7f25b7546b4714edbb0a585e6178", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08ef53d83e8c": { "name": "nativeChat.readSession#1", @@ -31,20 +22,20 @@ "status": "ready", "transcriptLoading": false }, - "09f715780ff1": { + "3e14b1ec5bde": { + "name": "nativeChat.unsubscribe#2", + "ordinal": 6, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, + "456781b97acc": { "name": "nativeChat.subscribe#1", "ordinal": 1, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, - "1c6ad14c561f": { - "name": "nativeChat.unsubscribe#1", - "ordinal": 5, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, - "459bc33ee698": { + "663babae1f1d": { "name": "nativeChat.subscribe#2", "ordinal": 4, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, "7b237e9824c8": { "crash": { @@ -72,6 +63,11 @@ "status": "ready", "transcriptLoading": false }, + "7eedfcf76fd3": { + "name": "nativeChat.unsubscribe#1", + "ordinal": 5, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, "8f91342d7840": { "crash": { "$rpc": "null" @@ -180,11 +176,6 @@ "startedAt": 0 } }, - "e488a88a0a50": { - "name": "nativeChat.unsubscribe#2", - "ordinal": 6, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1\"}}" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -214,7 +205,7 @@ "id": "subscribed", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -226,7 +217,7 @@ "id": "snapshot", "observation": { "sender": [], - "payloads": ["09f715780ff1"], + "payloads": ["456781b97acc"], "settlements": { "mount": "eb79a9b3682a" }, @@ -238,7 +229,7 @@ "id": "paging", "observation": { "sender": ["dadae0a56731"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -251,7 +242,7 @@ "id": "paged", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c"], + "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a" @@ -264,7 +255,7 @@ "id": "re-subscribed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -278,7 +269,7 @@ "id": "replayed", "observation": { "sender": ["d83fbb18104a"], - "payloads": ["09f715780ff1", "08ef53d83e8c", "459bc33ee698", "1c6ad14c561f"], + "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", "page": "eb79a9b3682a", @@ -293,11 +284,11 @@ "observation": { "sender": ["d83fbb18104a"], "payloads": [ - "09f715780ff1", + "456781b97acc", "08ef53d83e8c", - "459bc33ee698", - "1c6ad14c561f", - "e488a88a0a50" + "663babae1f1d", + "7eedfcf76fd3", + "3e14b1ec5bde" ], "settlements": { "mount": "eb79a9b3682a", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json index cd84dadae00..1e9dabb4988 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-readability", "family": "session.native-chat-readability", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "46d80cfd496b06ce42ff1ed985e513bbf49b5188bc1128c6d01a74675741935a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "19b2979d7fc2": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index 2b14e1cf717..406e4dd32ad 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-readability", "family": "session.native-chat-readability", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "08a9ab4f180f2d6bb44d2a23f87be0443e8dfdde15f966458270a1bb6f131e0b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0b8777edb86c": { "readable": false, diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json index 697560982a8..0bc701f4c35 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-readability", "family": "session.native-chat-readability", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "0c6afb12dce2c402dfd7ac24eb4a306e09fcacc78415ecb47b5320b2fe8102b5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0b8777edb86c": { "readable": false, diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json index 9df01ea4568..c8c9d6adac4 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.session-option-pick", "family": "nativeChat.session-option-pick", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "55f675f7d3f380db10dd966ae6d011927dbc7eb8f3abd36e09edf5043ad1131c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0aa1124bf746": { "settled": "settled" diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json index f295cb8c9a0..8f61c9059ce 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.session-option-pick", "family": "nativeChat.session-option-pick", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "57b60064eca4526e5d533de5862e7e86ba69b6722cd04692228bdc768486cd76", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0aa1124bf746": { "settled": "settled" diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json index 4012a927f30..5a50b473371 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.session-option-pick", "family": "nativeChat.session-option-pick", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "a180b7ae1f84bc69d7819c7c17b1e2915cb380e8ea8543d68fe6777feb90d0bd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0aa1124bf746": { "settled": "settled" diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index 6c2ef2da623..ef83d7f679b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-stop", "family": "session.native-chat-stop", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "a72f08c12c244912912be34deb3ec12bada6b74f1bc29c0034b347dcba9ddb10", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5cd1d4a9aed5": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index 296d4d7b349..9d3cd0ca1ef 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-stop", "family": "session.native-chat-stop", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "a7419d4b3e00d49ebdac7db4fa97ad9d3af9516201f9102beed0376b181e74a5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5cd1d4a9aed5": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index 1366bae9a1b..3495ee46fe0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.native-chat-stop", "family": "session.native-chat-stop", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "977ee5602e3a612d537686d17d15312f8b0e775df8800b27bb0d42b8642b4675", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5cd1d4a9aed5": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index e2e98cfebd4..e5f38ca21cc 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "3e8804f21d32370bb0bc48c4ea3d182748d48dc19d73de1b50005f18368566ba", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0c75127e9c76": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json index bb10f158648..f4dd70c5d1e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "b8265928eefc167de59c64dc62ebe40635007a5f73c87ec8b6eb5e0f6dc0ce00", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json index 8653789f0e5..34c80f9ff37 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "22f7711ed82a4d56f1886a746838e3eb85c555c9069694ba3aa958e121cc1ee9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0185c130a223": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index 8b9726edd6e..59ee5986ccf 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "7e05773966a18123aaae297aed9ac44bd841713de88c8a1fa30c31b324118f6f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0143e6e129bc": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json index d5232fa46cf..ba4ef89305d 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "nativeChat.terminal-write", "family": "nativeChat.terminal-write", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", - "scenarioSha256": "a89ff803859c60e5645126de8b0f423807c435dcd856fe8825721f71f3b5bec5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "098c51e345e3": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index a456b535003..ee5701b5b68 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings.new-tab-local-agents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "ef016c6b4b77ba0c40b6feba2f670398c641f8ce9f411896e3ae41f38d8be17d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index 6c076c3555f..70b4a9ba4ca 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "workspace.repositories", "family": "components.new-workspace-repositories", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", - "scenarioSha256": "41ac47445191a24de5e48877478bdab6fd9c030f48024ea43e053de7b6b68bb5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "19b2979d7fc2": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index 4cc144feba8..9d24f772569 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "24f8c05639f82bc5e32abe3864c3d01a0589e295369028929fed2f0c684f1d0c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0493957d4faf": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index ec67928b983..3aafec42f01 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "0495c25d6e5d8a84fe4192d7aa0e2901fe86ede19ac9c4d72dee27da6694878b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0493957d4faf": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index 5ae2f8256bd..6f844c31935 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.desktop-stream", "family": "notifications.desktop-stream", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", - "scenarioSha256": "359add5203e68fcb85586f06babb694ee9d548e1dc58481e6d03871ab9f922cb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "18a100fb6bae": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index 64e2c8c1cd9..de0ad9f0796 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.display-test-screen", "family": "notifications.display-test-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", - "scenarioSha256": "e19c4ff95d568edbb5c0d6058eb17843be31bdfd528825985963f8ece8cbc652", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bfc5a373e4d": { "name": "notifications.testPush#1", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json index e255279ddc7..fe8794a169f 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.display-test-screen", "family": "notifications.display-test-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", - "scenarioSha256": "3edeb5f744ed832104df750544b99fea1a1cd9d1b8d2f3f9a5e7c13b415edc3e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bfc5a373e4d": { "name": "notifications.testPush#1", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json index 6495277b7c7..325dede690a 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.display-test-screen", "family": "notifications.display-test-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", - "scenarioSha256": "53827d812956f05e86d72e44effd017270328eb36588ca90431cf9a8ae97b146", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bfc5a373e4d": { "name": "notifications.testPush#1", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json index 16673d73533..ec2d5f54abf 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.display-test-screen", "family": "notifications.display-test-screen", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", - "scenarioSha256": "37439be5aba4a84cf6c12c5f583a0334405792387edb72ceab388e7c64109eff", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0bfc5a373e4d": { "name": "notifications.testPush#1", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index 4a85cc3448f..95b36ed5ed7 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -1,19 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "notifications.push-registration", "family": "notifications.push-registration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", - "scenarioSha256": "ac2b214ece34dc25aef2b73d020e04343f81cd48b3d243508be92cfdba01eb45", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "2d5e96e12ea5": { + "2e2cf4af7c51": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -24,6 +15,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -57,10 +49,10 @@ "2e71f48d7fa1": { "register": false }, - "70a555b8bb94": { + "692c015040bb": { "name": "notifications.registerPush#1", "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"apnsEnvironment\":\"production\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" }, "7ed3d39f0607": { "status": "fulfilled", @@ -75,8 +67,8 @@ { "id": "not-registered", "observation": { - "sender": ["2d5e96e12ea5"], - "payloads": ["70a555b8bb94"], + "sender": ["2e2cf4af7c51"], + "payloads": ["692c015040bb"], "settlements": { "register": "7ed3d39f0607" }, diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index 85817d1e175..c4a2fa31e02 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -1,24 +1,15 @@ { + "goldenFormatVersion": 6, "operation": "notifications.push-registration", "family": "notifications.push-registration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", - "scenarioSha256": "90f49e430518bfc6e662da1d0c55b0084f30ab54dabdd15293b1f8ad9d2fa592", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "70a555b8bb94": { + "692c015040bb": { "name": "notifications.registerPush#1", "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"notifications.registerPush\",\"params\":{\"platform\":\"ios\",\"token\":\"apns-token-1\",\"apnsEnvironment\":\"production\",\"filter\":{\"onlyWhenDesktopAway\":true,\"sound\":true}}}" }, - "79ed32a5bb02": { + "810a020dae97": { "name": "notifications.registerPush#1", "ordinal": 1, "args": [ @@ -29,6 +20,7 @@ { "name": "params", "value": { + "apnsEnvironment": "production", "filter": { "onlyWhenDesktopAway": true, "sound": true @@ -116,8 +108,8 @@ { "id": "settled", "observation": { - "sender": ["79ed32a5bb02", "caff83b6525e"], - "payloads": ["70a555b8bb94", "d92e80fc55a0"], + "sender": ["810a020dae97", "caff83b6525e"], + "payloads": ["692c015040bb", "d92e80fc55a0"], "settlements": { "register": "84e5ca07cb7a", "unregister": "84e5ca07cb7a" diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json index 92c3d0be569..4f4eeef3c4b 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "e4fb7aa3b071c1207f206adcc0f31e92b6bb98a80f86a0772e48b5cebcab24ff", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "061b247f2805": { "name": "host-saved", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json index dbaade09b51..972f3317c63 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "5696ea2a62bb3f24902305f8bac0c4ae8f1e505f359db76fd69aabe353adae86", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2b5606ff50ed": { "name": "candidate-closed", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json index 703f2d44b8a..5493980f25a 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "pairing.pre-profile", "family": "pairing.pre-profile", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "9ab72231bf97fbe7eed232019c56568a9411433835eae424628af62c7c6a10c1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03106dceb986": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index 8b55717c760..4b273ede6e5 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-branch-context", "family": "session.pr-branch-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "d1b208a7bee947a603949fdc1f0d145e8c5576926f89f3e32330585f3a115290", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fc11d9bf2a5": { "name": "worktree.show#1", @@ -181,7 +172,11 @@ "startedAt": 0 } }, - "926edf8b660b": { + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "c0f7fd87f4a5": { "name": "git.branchCompare#1", "ordinal": 7, "args": [ @@ -220,22 +215,18 @@ } ], "summary": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", "changedFiles": 1, "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "status": "ready" } } } } }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, "d0e28a73dae6": { "name": "git.status#1", "ordinal": 1, @@ -410,7 +401,7 @@ { "id": "identity", "observation": { - "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "926edf8b660b"], + "sender": ["d0e28a73dae6", "0fc11d9bf2a5", "189a62313626", "c0f7fd87f4a5"], "payloads": ["d45add7a8589", "ea4c904bfb5f", "648638103acb", "622e5aadb43d"], "settlements": { "identity": "ffc37850babd" diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index fdffc14bf9e..e574fdd4b11 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-branch-context", "family": "session.pr-branch-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "bd715681a856254e0c374b504cece07e5df4c9a75fa2b97c35498df12210fbab", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "191e3c96ad53": { "name": "github.repoSlug#1", diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index 5135bf25068..ade0b135294 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "73cc5da2649b9687bb0d8247fa4ecf3c085746cf398515e8d2b40be2ed0da688", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "14120b04af8e": { "name": "github.project.updateIssueCommentBySlug#1", diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index b51583c5b23..838a2347ca0 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-comment-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "f425131d29fee8826c00a550fb537d0bd1a37bbaf3b33992984d5e04a990a512", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e0226959068": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json index bdd5c5f00b5..aa03c1179ca 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "70a94f45dbf7d58d9024c1cc4c94edd98fa48cd5ccffe8f2b7c53fa3e55d18d3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0724cf7c0f6d": { "name": "github.updatePRState#1", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 5b9696a333c..e6948b4e1dd 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "e9291209234bacab12201f4c13bb592e06bd405215389e19f0754c23e79eb197", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "053eb7126f9a": { "auto-merge": { diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index 9c63ea6f1ac..61a0856a624 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "ba21f7fc3966cb1e3c1e59d6e8a8b184fa0559bf3037365391ce23e364befd7f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "08e5cc0c504e": { "name": "github.prChecks#1", diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index 000138b09f7..57f5f2e1139 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "4246e7ffa62e85aac489c561171a6468f862b7f18eefac9926b65073616b6d35", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "083bb0224d9d": { "name": "github.prForBranch#1", diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index ac5d882d2d5..3cffbfeb9ea 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-reads", "family": "github.pr-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "b2445b299e18664b698d659c5041860b8a253314687666ae467aab441ab07235", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04c85ffba34f": { "pr-for-branch": { diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index 0f676a3ec3c..ca2fb4662af 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-sidebar", "family": "session.pr-sidebar", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", - "scenarioSha256": "1b489d319f6517fe2ee33beaeedf4e0cb0f531c952dc7e9f833ca814366d60cb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "07639c4f1e7f": { "name": "hostedReview.forBranch#1", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index b620dbb5ff3..d4f2cd7c2d4 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-sidebar", "family": "session.pr-sidebar", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", - "scenarioSha256": "d4da89a37325ac4e92d9eae8de33f9cc6d9414fcf89a4f44064a7b1e4254102a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "07639c4f1e7f": { "name": "hostedReview.forBranch#1", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index 8b70312a91a..6362d519aa0 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-title-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "071e745453795d18d683aaab63e810783e3cee4927b47425c20a3d915397d0dd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "55b590476b96": { "name": "github.updatePRTitle#1", diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index f3925091c1e..4a655d48dbe 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-mutations", "family": "github.pr-title-mutation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "669260c675021b37252dc5023a7a78e4e90536c2f35d9a5da3e53778e6a0cf52", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0400bbb4177c": { "title": { diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json index dd9dc7f9932..cbfd4fbdbac 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "ca8e7ca631e72fbec9f3264a2ece10f171c258dc888922e4931ea481a70ecc62", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index 6409255dde3..0b7a6ad752b 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "b7c9498eea67df2f670911e3b868a44ba77833813c17e7800dba52ebff530350", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json b/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json index a3a89906998..e7b46ef15e4 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json +++ b/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "8307180e88773739a587bb3000327888da9ab609554ec66ea46d92b5a816221a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/pr-triage-update-required.json b/mobile/rpc-foundation/goldens/pr-triage-update-required.json index 9c177b4f7d8..f9a4ac0c94e 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-update-required.json +++ b/mobile/rpc-foundation/goldens/pr-triage-update-required.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.pr-triage-launch", "family": "session.pr-triage", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "7fc74743e625d338b27a32280692132b5b085cfbb43ecfd0a1ac7caaddbcd459", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "c2a44248892e": { "launched": { diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 5fc9131610e..a43eaadb7ac 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "819fa73c7700b4d526da91c37558a6498008d745d1debcc26e6bb757550ebf99", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index 7f025ba6705..d31c44bdf87 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "500396d72abd2f73d11ef066bca3f88798c8cbdaef09fa7c1c8d1fbaf0b3b85a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index c35f9f15bb4..6695da42b48 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "daf68df8840ea6872521d823cc17e1e5de3f3a74a8855465fcf40cc276e9c2ce", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index ead340e9da1..422cb2f4f86 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "61e36caf6b3bb01c3ad0db282b7f0fbc0f300d40184f9cf3d7e4e3a3194a4f2a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index efb5cb44258..372f8e59c14 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "notifications.push-dismissal", "family": "notifications.push-dismissal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", - "scenarioSha256": "318fab8c1efb1786bbdaea7f13b769952c1ce463bc5504dca6a9f545e905b7c9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4d602d4523e5": { "name": "notifications.getMissedSince#1", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index 9b474b2fe94..b37cb742fa8 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.quick-commands", "family": "settings.quick-commands", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "42573387533f75122f626ce6ec81c1e61fe54cde5df416154bb7cba132f53309", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3a752c2955e2": { "name": "settings.getTerminalQuickCommands#1", diff --git a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json index f0a3927eb9d..abbd62871cd 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.quick-commands", "family": "settings.quick-commands", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "a621156bbb662c78a0baa356b60d0af41d10a7bd14e54f23be0581a59377cec3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "293828e985f4": { "name": "settings.getTerminalQuickCommands#1", diff --git a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json index f35f31ef011..273ecb719c0 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json +++ b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.quick-commands", "family": "settings.quick-commands", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "72517336e451e1e078135103073c1821e8af27c0702f6dc83b9a686fe7ff8c04", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "293828e985f4": { "name": "settings.getTerminalQuickCommands#1", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index 128b5683855..ed4dbd617e4 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.direct-upgrade", "family": "relay.direct-upgrade", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "a92bccd183127829b6dfd85add28e42370d54990f63214940b1c584f7fde56a9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "10098087f961": { "name": "pairing.getEndpoints#2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json index 49bb0d6916b..9a69be444e6 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.direct-upgrade", "family": "relay.direct-upgrade", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "7d5cad367e76767b039fc5ebc15837930f02c5e1bed33ae7ca4695bae56287bd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "6c43da669636": { "journal": { diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json index 93d35123826..59b42b7f25f 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.pairing-recovery", "family": "relay.pairing-recovery", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "8dc3fd27c5720276608ca8743990e4f57d94eab84f620e9aa42702a4686fd5a9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1020838fc104": { "name": "pairing.getEndpoints#1", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json index 01ea2c1dd1c..399e8582ffd 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.pairing-recovery", "family": "relay.pairing-recovery", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", - "scenarioSha256": "7e54c3af4b8b8e6eac007267fb96620283b735491883c505401c79656d920ca6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1020838fc104": { "name": "pairing.getEndpoints#1", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json index df749f8a8ad..b37e1d21bdc 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.credential-rotation", "family": "relay.credential-rotation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "c786fb19f9593ee60238e42813561edf6f5e976fcf217dff8de977a333fe8451", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1d5e608b0cda": { "name": "pairing.provisionRelay#1", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json index 9b00ed66ae0..797db9ad21a 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "relay.credential-rotation", "family": "relay.credential-rotation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", - "scenarioSha256": "96df784c1d56de3bdd04afe20ab019339c7d9a616528ca2617e2fffe4f0157c8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "351d2bec2471": { "outcome": { diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index 111141a241d..94c2b4e2ece 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.review-branch-diff", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "36dbf57e82d87819b403839c22044209f0f399057f62efa7004bb8b899fdbe81", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "365c17523d76": { "status": "fulfilled", @@ -40,12 +31,12 @@ }, "snapshot": "unloaded" }, - "551c785629a1": { + "ce804facf021": { "name": "git.branchDiff#1", "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"base-oid\",\"headOid\":\"head-oid\",\"mergeBase\":\"merge-base\"}}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\",\"headOid\":\"f0e1d2c3b4a5968778695a4b3c2d1e0f98765432\",\"mergeBase\":\"0123456789abcdef0123456789abcdef01234567\"}}}" }, - "714ef7b515d2": { + "d24ea9d9187c": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -57,10 +48,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo-9::/w" @@ -93,8 +84,8 @@ { "id": "branch", "observation": { - "sender": ["714ef7b515d2"], - "payloads": ["551c785629a1"], + "sender": ["d24ea9d9187c"], + "payloads": ["ce804facf021"], "settlements": { "branch": "365c17523d76" }, @@ -105,8 +96,8 @@ { "id": "no-compare", "observation": { - "sender": ["714ef7b515d2"], - "payloads": ["551c785629a1"], + "sender": ["d24ea9d9187c"], + "payloads": ["ce804facf021"], "settlements": { "branch": "365c17523d76", "no-compare": "38fb361f406c" diff --git a/mobile/rpc-foundation/goldens/review-create-agent-refused.json b/mobile/rpc-foundation/goldens/review-create-agent-refused.json index 4b3e87f2df7..4b3e557ab71 100644 --- a/mobile/rpc-foundation/goldens/review-create-agent-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-agent-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "0816742351babdb260fcdba92d0020e0216c8ec0d106b560357f295671c83fe2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index 468c34e78f7..d8e9ed98991 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-load", "family": "session.review-file-diff", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", - "scenarioSha256": "30f0d2ecb8ab5a51a85037196325d5bbac53eb48c5d59a5af62e3ab1fc9420e7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "168eeea61142": { "name": "git.diff#3", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index acd150c9b2a..d2a71c645ea 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.review-git-mutations", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "7981ac87c4397b9703330835327c70828cb5a76633f1c4803ded1b66520d184d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2fc890e4b748": { "actionError": { diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index b85c016c15a..755ca882f77 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "9f1c39e91a97266b0a550d2b2d061592ae050078401ef23177dd2e00db67bf04", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02c4a9358dee": { "actionError": { diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index e99516ff777..a7bdebb2830 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "c858cfca59148548146b4c0a775e9518d3ab826ef268ccc9af60b04e52cc9e3d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "6bdf0d77510f": { "actionError": "Workspace is locked", diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index 569e83371df..e52844e2ae3 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "2f0a6aa8ce100edbce9ccf64c81efe64356fccbdcd2e16f561360e2b11b5700f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e2ced5a5f5a": { "name": "files.openDiff#1", diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index a05b6062e16..910b872f335 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "7099d7311b2046dde21a2750201718995c0869bc4f17fb7f3ce2b997ce0e6506", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09ce77ea89fa": { "name": "terminal.send#2", diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index 97ad2e2b3c6..d97ef0a7628 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.review-send-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "8b4a06c1b2918d5ed5f40102c1c7cb411e02aba5e7601e98658d673634784bb0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "697177595a0a": { "actionError": { diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index c26300ecae4..11f2d92e1a4 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "793e954960d371c6477d4cd3d70a5215c0bd90764684eafdb1e1a2e09790cc86", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2fc890e4b748": { "actionError": { diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index ba13c5d523f..db2b1a6693a 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-review-actions", "family": "session.diff-review-actions", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", - "scenarioSha256": "46b720fed417d29f79dbde4cc7941579f2d6a2f920bae24234537480079e7de5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36787858f78b": { "actionError": "Cannot discard during a merge", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index 5942b67df03..b0ba24b879f 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "374129def6baa0e06b808c067831820966638d79d7a782e96c1f2f891cc9dc86", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "006a91b993e0": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index b8452d82410..d2aaf397dd3 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "97a8b8f5b9a7c7467745666becee07f5dfc57fb283d4e80dcbe7941509177598", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fa683226c0a": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index 50df2e2ddab..099f6bfdfc1 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "25a4762d735dfb4979e6ef31b9fdb380941a824a54b45b3d08ddb2cde25c2eb7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "112952c84c31": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index ffee9e6aadd..035b813b500 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-base-ref", "family": "git.base-ref-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "f3b193f93f6c9de41d11e706ecbd99648eb2ed41ccb7c66cdb80c934e780ed7c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "006a91b993e0": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index 7b1b824e2bc..9f05cc69ac7 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.branch-diff-preview", "family": "git.branch-diff-preview", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "819d010b9a8bb2741b09f104415f648e0e65d797882a2152ccfa4fb8a107cafc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "009bc53fab97": { "preview": { @@ -20,7 +11,16 @@ "truncated": false } }, - "040bae459f65": { + "7ef737811204": { + "name": "git.branchDiff#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo42::/p\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678\",\"headOid\":\"f0e1d2c3b4a5968778695a4b3c2d1e0f98765432\",\"mergeBase\":\"0123456789abcdef0123456789abcdef01234567\"}}}" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "b699fd109439": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -32,10 +32,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -53,7 +53,7 @@ "startedAt": 0 } }, - "1908cad2fe5e": { + "dadf774f4471": { "name": "git.branchDiff#1", "ordinal": 1, "args": [ @@ -65,10 +65,10 @@ "name": "params", "value": { "compare": { - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "baseRef": "origin/main", - "headOid": "head-oid", - "mergeBase": "merge-base" + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" }, "filePath": "src/app.ts", "worktree": "id:repo42::/p" @@ -96,15 +96,6 @@ } } }, - "424c6bc6b69b": { - "name": "git.branchDiff#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"git.branchDiff\",\"params\":{\"worktree\":\"id:repo42::/p\",\"filePath\":\"src/app.ts\",\"compare\":{\"baseRef\":\"origin/main\",\"baseOid\":\"base-oid\",\"headOid\":\"head-oid\",\"mergeBase\":\"merge-base\"}}}" - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, "e5400edc0fdd": { "preview": { "kind": "loading" @@ -125,8 +116,8 @@ { "id": "diff-pending", "observation": { - "sender": ["040bae459f65"], - "payloads": ["424c6bc6b69b"], + "sender": ["b699fd109439"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "9270aeb7d9c6" @@ -138,8 +129,8 @@ { "id": "settled", "observation": { - "sender": ["1908cad2fe5e"], - "payloads": ["424c6bc6b69b"], + "sender": ["dadf774f4471"], + "payloads": ["7ef737811204"], "settlements": { "mount": "eb79a9b3682a", "open": "eb79a9b3682a" diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index e958945db16..4c6a8b461bf 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -1,74 +1,66 @@ { + "goldenFormatVersion": 6, "operation": "source-control.changes-load", "family": "git.changes-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "f2c4d64b3761fcd036d9538155037edc4e35492e67ce5eb158372ddc76e55cc9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "02adc1911747": { - "name": "git.branchCompare#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "git.branchCompare" - }, - { - "name": "params", - "value": { - "baseRef": "origin/dev", - "worktree": "id:repo42::/p" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - } - } - }, "02d5719960a0": { "name": "source-control.status-load-success", "ordinal": 6, "value": {} }, + "1d461278a0ae": { + "branchCompareState": { + "kind": "ready", + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + }, + "screenState": { + "kind": "ready", + "status": { + "branch": "feature", + "entries": [ + { + "added": 4, + "area": "unstaged", + "path": "src/app.ts", + "removed": 1, + "status": "modified" + }, + { + "area": "staged", + "oldPath": "src/older.ts", + "path": "src/old.ts", + "status": "renamed" + } + ], + "head": "head-oid", + "upstreamStatus": { + "ahead": 1, + "behind": 0, + "hasUpstream": true, + "upstreamName": "origin/feature" + } + } + } + }, "21b68b754dce": { "name": "worktree.show#1", "ordinal": 3, @@ -131,58 +123,6 @@ "startedAt": 0 } }, - "35fa5a183b27": { - "branchCompareState": { - "kind": "ready", - "result": { - "entries": [ - { - "added": 2, - "path": "src/app.ts", - "removed": 1, - "status": "modified" - } - ], - "summary": { - "baseOid": "base-oid", - "baseRef": "origin/dev", - "changedFiles": 1, - "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", - "status": "ready" - } - } - }, - "screenState": { - "kind": "ready", - "status": { - "branch": "feature", - "entries": [ - { - "added": 4, - "area": "unstaged", - "path": "src/app.ts", - "removed": 1, - "status": "modified" - }, - { - "area": "staged", - "oldPath": "src/older.ts", - "path": "src/old.ts", - "status": "renamed" - } - ], - "head": "head-oid", - "upstreamStatus": { - "ahead": 1, - "behind": 0, - "hasUpstream": true, - "upstreamName": "origin/feature" - } - } - } - }, "586549d3cea5": { "name": "repo.list#1", "ordinal": 4, @@ -217,6 +157,57 @@ } } }, + "7ab1473c6d87": { + "name": "git.branchCompare#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "git.branchCompare" + }, + { + "name": "params", + "value": { + "baseRef": "origin/dev", + "worktree": "id:repo42::/p" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "entries": [ + { + "added": 2, + "path": "src/app.ts", + "removed": 1, + "status": "modified" + } + ], + "summary": { + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "baseRef": "origin/dev", + "changedFiles": 1, + "compareRef": "feature", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", + "status": "ready" + } + } + } + } + }, "8601995022f5": { "name": "git.status#1", "ordinal": 1, @@ -434,12 +425,12 @@ { "id": "settled", "observation": { - "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "02adc1911747"], + "sender": ["d7f430c1de90", "21b68b754dce", "586549d3cea5", "7ab1473c6d87"], "payloads": ["ddee63fd3bf5", "e9aa1da11c9c", "92bee926780c", "fbb97a8eabc3"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "35fa5a183b27", + "state": "1d461278a0ae", "effects": ["995fa685a11d", "02d5719960a0"] } } diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index 2f9d956fb0c..51c6db4ae6e 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.commit-message", "family": "git.commit-message-ai", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "013db25622b180a8333bb1ef27c22a5b1f8e04148201201e5cc3413a10640781", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "25b63dbb4c58": { "name": "git.cancelGenerateCommitMessage#1", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 154bc9c8cc1..b4875b391ff 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.commit-message", "family": "git.commit-message-ai", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "15bf6c17f4b524dfbf5373b2eeed61ee2e659421cf8b6e3cff6c0378c7692cc1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3081187d26cd": { "name": "git.cancelGenerateCommitMessage#1", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index 8afa223e0f8..0ce50bb2681 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.commit-message", "family": "git.commit-message-ai", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "540f05d84d1cbffd566af933c547838c75500bb5d708e8558c21fe8131d724e3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1290c04bc26c": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index d2ba7926178..ecaa2c62fc1 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "7d6099248aa6a2ef19f2e169ff917af794649d9d64d139aa9ffeea6a41355ddc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2cc2895eb25e": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index c6b49807d94..cc6712d97c7 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "447f9b3d697dbfe21cb7fb6e12d1bf5fa94b023b7e1697bdc2dc82ce7072183f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0347742c8f12": { "name": "hostedReview.getCreationEligibility#1", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json index eea93b6b063..0a08617478a 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.create-intent", "family": "hostedReview.create-intent", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "7a032109038c14b0a0f254097939b877eba62c10ba7b963070988bb4a1c06ef0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e4c5b9b4b61": { "name": "git.commit#1", diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index f5976e7eec6..0a0ae7a2d46 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "2deb0435ef63a3e0102e28f2f3f331039486d193d1e1ffdfb53ad86d3ff039f0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "33548e1a94b7": { "name": "worktree.set#1", diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index d2a7744e237..e5668af8453 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "0f86b3e6059c48cd327c55df2452a9bc6ea85584ffbeacafad496f600c20e06f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0122a4affef8": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index 3500b4a605a..265cb1b3360 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "5008e69a8e396b1deccd98712d92650a630f971cd76a02862a461afb8617b8a4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "38143ba8e1d0": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index 4246171faf0..0ca5809ea79 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-create", "family": "hostedReview.create-chain", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "ea71081982a101d0f8624707b59c99de9981e9b1d1bafa25c66d575e3f876ff4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "38143ba8e1d0": { "name": "hostedReview.create#1", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index f9cea076c93..ba0a4e4bfa5 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-eligibility", "family": "hostedReview.eligibility", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "f6a1595073abe11b33973e8865900a1d849f44221961da5c12ea13aa696f6490", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "24bd84c9fb40": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index 650632d4a9d..2032cd53362 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.history-commit-files", "family": "git.history-commit-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", - "scenarioSha256": "27e71d989da152adcbe40a02186df8b8a2e19a6a0e8bfe727e2133c4360f7639", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a26f0498c96": { "name": "git.commitCompare#1", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index 6ab05a8ff9a..3ecbce4e6f7 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.git-history", "family": "git.history-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "83f61085a91458bad529905ecc6fe240c598cddfe44a56dd497b8aed9fb8a7e5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e92a08f8380": { "name": "git.history#1", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index dc8de943cb4..6e0bde4ba89 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.pr-link", "family": "worktree.review-link", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "42b0304b2fdce08b7ff52ec979dd9f199f368e5e4ef0b5370acc417909b592b3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1852c739af4f": { "linkedPR": "unread", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index ebb5e7ec15b..fc978a58e4a 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.pr-link", "family": "worktree.review-link", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "4520bd54a55eabfe6ec64a4b2f824f095f98b2fffd1bf22fe4f9ec7f63cbfa3f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "17ab8ab0a9f4": { "linkedPR": 7, diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index 34f35559c0f..4f5a1f0bf3c 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.pr-link", "family": "worktree.review-link", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "b65bad4f8c0ae0b686f6c3db93bd43ffa072ae426f978f1a86ad8008fb24fa24", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1852c739af4f": { "linkedPR": "unread", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index 31773808f1c..e822e91fbe0 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-eligibility", "family": "hostedReview.eligibility", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "9de96287a4dfa6cf5c9a8b683cc696fdc2cd387f86f231e22ee3f100a2e778e3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "478df34536dc": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index d526b09470e..9fbe322ab53 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.hosted-review-eligibility", "family": "hostedReview.eligibility", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "adbdfcc3895cc04d830900de518e689c9e63f6f75569127e1fde24488658e8a0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "478df34536dc": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index 1dbb4649be9..395c9a8ebe0 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.remote-prerequisite", "family": "git.remote-prerequisite", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "ec2847b4af357d8564d8e0a9a1072713c1afd7ff86ba69e9c83c056a6841ee39", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00e8a3bac22f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index 18a8b027d3b..feda7617ad9 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.remote-prerequisite", "family": "git.remote-prerequisite", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "6cf6ebd20adc4cc76a12d3424863ee9db2b24f36593664a1f4e0e05de9a53d39", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00e8a3bac22f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index 319b4979fcf..7d2f553452f 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.remote-prerequisite", "family": "git.remote-prerequisite", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "e6e8197a541cd73e5811a1f28b0dbfc414a4d34c1ae6929fc1bbae1213820674", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00e8a3bac22f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index 6d82311a78f..19eef9b10db 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.remote-prerequisite", "family": "git.remote-prerequisite", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "523d1ee21e3871a4dffc32f48d2e28c31ecea48cbf3f842acffc8355be06b14b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "69f421c50546": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index a8adca2c4b9..7d32bd1805a 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.session-diff-reveal", "family": "session.tab-reveal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "00b26cb279b0a934df98d93ba98a4a0c79e302c7690c582e778dc0156ab4f235", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1da2ff4ec6f6": { "name": "session.tabs.activate#1", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index ab99918545d..f00e20f85ba 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.session-diff-reveal", "family": "session.tab-reveal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", - "scenarioSha256": "fcd61f1ef46c42889827a87876239534b851c425f8ef7a9405ea95b8d07d2363", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1df6388356e5": { "name": "session.tabs.list#4", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index e6aa5a4faa6..6bb51fe6865 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "db1fb2a584cd806028b9be861283a63aa4836c83f61558f3d518ddbb7a59498d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "342343f97688": { "name": "git.commit#1", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index 27caff58614..7a96be5a02f 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "dba1676583dc832ef059285a6bd4c3eefe6be0230e42100cb9d7a125755d136b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "342343f97688": { "name": "git.commit#1", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 434e9f70cf7..c108efe47c7 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "de079de9cc21bfb40da6e1431273b10c3e2a5b5402b91b2b8a85c8d7ac41bc97", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "342343f97688": { "name": "git.commit#1", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index da0aa2e6a9f..990308832f9 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "a29d518b2d075e8e4811404e0fcbf8948fbfe53a3aefbf0948cb2f2e622e8cbb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "17bb401abe83": { "committed": { diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index f63660a3cd9..0f8179d2a59 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "37ba7780ca9525ab313c0a9c781ce5bb26344e63af9272c32ae889621f383b2b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "6decf368b25a": { "committed": "uncommitted", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 7b5a556643b..4d41cf1b450 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "source-control.review-git-preparation", "family": "git.review-preparation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", - "scenarioSha256": "93193acd57d6f00abc8e6c22ec3a8f1ca6ce7c5808d906d0aa7c11e41dab4635", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1858779f58e9": { "name": "git.status#1", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index 4f853b2b96f..3d84cdf4f0b 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "linear.issue-detail", "family": "linear-detail-barrier", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "b59fb599dd3a5fbc79bb8602dcec4b1c51a392c662efab7efc8324fc718ce8de", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0056f47b204a": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index bea3dafeabd..136c35277e6 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "fbd311a377672a9335521c30734880eea1b04bab0aff367854c1deebcf66b105", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "014c74879acc": { "name": "preflight.check#1", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index 68d4feddea3..1937c9e6229 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "2726d71130f623e3ad02c168c13269979ca6f84703bf1c5aaf36bd4432dfb516", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index ddbc2f475c2..f8d65fba25b 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "664eba1468e229f9ac2dced262e7ad896ead01688dff4c570f397c3f8594efd7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "031c2df78b67": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 4eb88063ebf..555373f888f 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "7bfba3fae1dc33acf40e8a955bbfccf28580b3daee3270dec6a15e6cefd45a84", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index b2d3830f925..3494bdad897 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "02e3ca10296704b5478185e9d3dc0136596a2ee57580d7f9268672568dab4cd4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index 66a95b727a5..4a56895593d 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "eb980fc027ca0200212ba6ad3bf9a1ab3460936a7bb4b04353a9462eecd287a1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e7f9a45facb": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index b04cda06749..af938d0716e 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.browser-tab-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "b4b584b1da9569f18475211ff84d157de3e5d3f300719f6848ac51c01d011531", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4a842fa3c9c1": { "name": "browser.tabCreate#1", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index 1d77a1a3c6a..185d964db1d 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "59b5f0aac2f8c1aab5fc3a457fb69e1a2f46cc88c617c25e638175acb7f7c0cb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7ed3d39f0607": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index 9e18d6c81d8..12284d67ec1 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "36dbd24dc3d24be8c14217ced1963d10ef8264438729dd146cbff79d9fbdf279", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4a842fa3c9c1": { "name": "browser.tabCreate#1", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json index 5a53b692c8e..4ff435ffc91 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "be8d4b4be07b0ee5988471b26813d7d0d2a97fbd789b52e7ce00dd09a2e9d75c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12041361d59e": { "name": "files.createFile#1", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index af1f1da0e63..3ec9eb75252 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.content-create", "family": "session.content-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "4118eea1175cba0f15174072f5715f9054ded09a4cb0c359fc4ee41ad00e9440", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3e7bfd4c59c3": { "name": "files.open#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index db66770d615..682d535ae8f 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "df99b9c0ebcffb3d8f173c87783862edb0a66befcc3cd73f1096871f45963913", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2977cbddd89f": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index f78621dd39b..cb43be0f20a 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "fb9828d5e13917ce394061b2d702890e5198df04ce803363b2ce9f939921d00e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "27c32960f51e": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index 8bc650492e4..df171265c4e 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "e9c32bdf3357ca5d69ac07cb7bbc63fe279c1a23c44e5c79fa0b1db4fd1c55dd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "023a38d473d3": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index d019905f012..1e386d1cf5c 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "726e19307f1e82f0c8ae9a555ec9c27c8eea7f8f40fa700dc0b71c54ecc9ca11", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2977cbddd89f": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index 5dec9e36cef..065f894c5fa 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "4dfbe31daae64a03fc588f2095235fdd9b37f26ecccefe596c35c8ef8f623651", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4c528a84c114": { "activeHandle": "terminal-0", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index a279744b04d..23fc85c6d45 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "fd654a7e34ae8004543a34a7a301d51858989fdc2bffb02ddf6edb814071b22d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2977cbddd89f": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index 382ae3b6658..6aa1490a771 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "da78ad25ea15e1c714f0147d34207529362743a56a519153f36b09345dee0ebc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "246e4444bf16": { "name": "session.tabs.createTerminal#1", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index 44072a16f7b..5821188abc0 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.create-terminal", "family": "session.create-terminal", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", - "scenarioSha256": "f1952a0e2c9108dadf2f5b85ac1f43ec9db09db0ce81a575bb4dd3a8d18d9c83", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "21e2b9cf755e": { "name": "fetch-session-tabs", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json index 486e22ee91f..586afc30c3a 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-notes", "family": "session.diff-notes", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "5ab16800f82813778078e84739e0ac72886c145d20789dedcea9428ee31b9182", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "270b4ca66aa7": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index 43ee9a7704c..c83017fae64 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.diff-notes", "family": "session.diff-notes", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "795286ff495a243059a3eb55ccbbc9b4adfdd2034258f91f9182e83c7492fa60", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "270b4ca66aa7": { "name": "worktree.show#1", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index 1464aee2675..2581381e00b 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.tab-documents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "82c1d8e87e0a87c1c1a6dba7bd4f61e08f77fe0ae1b3758d1b5543bd9719a53f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "023c5158861a": { "name": "files.read#1", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index 33906f96031..a0a1babc809 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.markdown-disk-fallback", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "29565833b19968faa701f656d166050419f149d6bb76d1f63f4dd3af76b8e896", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "44abbe2af7aa": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index aff12975952..44c9bca57df 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.markdown-disk-fallback", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "8375df17afabf282a1d37ffaa3a334b1fa9105ba04e6618f16082f66bfe3f76d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0d6b0adc036f": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index 49b8f03cfcf..e77dc6a08e2 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.markdown-save", "family": "session.markdown-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "76bccdabb78dc3f16b36987f6b7cefbe41e08167fe39612620e27ad476089f93", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "48e7060a8031": { "name": "markdown.saveTab#1", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index a55ed7cac7b..b2d51593dfe 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.markdown-save", "family": "session.markdown-save", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", - "scenarioSha256": "a2b5b73b2455f9efc48361e4b96ab1d3ecc3d136459403c9c3678104604cd774", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36de5fd645a3": { "markdown": { diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json index d4f03e527ca..53e6780530e 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.tab-documents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "f46cef9d1c6a5ed6d8b6f1d180cdf5c666f52e043b70feb07e5fccee885ceeda", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "44abbe2af7aa": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index b9426e53cb4..474c47b7073 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.tab-documents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "f88c39d410655b229aa740b45ccdaa10186f41f7c6cbff9fed6eaee3f0a55844", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "44abbe2af7aa": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index db44a855f00..4e6665a2750 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-documents", "family": "session.tab-documents", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "63227f28110e90acac78058baa875b1bc7f8895b5033e47016a2ffa9f42f66ce", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "31e723411add": { "name": "markdown.readTab#1", diff --git a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json index 05e12d59cd5..56619f83bd5 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.startup", "family": "session.startup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", - "scenarioSha256": "efb7ff45ac09f1b7f0f3ad7f1b6f09d4d5c349e22de7d9c4d63ca93530552418", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0439d4e9a9bf": { "name": "fetch-terminals", diff --git a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json index 9b75f0a29ee..560076b0599 100644 --- a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json +++ b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.startup", "family": "session.startup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", - "scenarioSha256": "82ff29efaa6ee0bf49ce41bfe7dff2083d0f942c10cc9d8bab48925354d17519", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4f4cd2a4f041": { "name": "clear-pending-live-input-commit", diff --git a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json index dc0af2b1c43..999d0b23ad3 100644 --- a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json +++ b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.startup", "family": "session.startup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", - "scenarioSha256": "f540e4a06380a6a5d82d670230c76b7afc2c60302bc27dda13156b620095c5cc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2bba58da0410": { "name": "fetch-terminals", diff --git a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json index 9ba927b4a88..00e39fbc2f2 100644 --- a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json +++ b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.startup", "family": "session.startup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", - "scenarioSha256": "18d0bbda11a394a13d0eaea12b8748dbeede1ae7212907b0ca70a9bafa037424", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2bba58da0410": { "name": "fetch-terminals", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json index 90fc3c39d9a..512766198aa 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-activation", "family": "session.tab-activation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "72a972996461cc58bda2b1c11dbb4ecdbdecd5ff2f977c3d61e40d635f63c1b9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36e84700f75b": { "name": "terminal.focus#1", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index 632862d8281..447ade8967e 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-activation", "family": "session.tab-activation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "e5049c9ee2aef93194adf1b9540c1eb4b085e6f975648c5ed605718d19fc6afa", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7a0a4e34e537": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json index 54bac0ec3e2..abe6745c411 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-activation", "family": "session.tab-activation", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "44e25e8624c6d7f072c5f7aa3c706df29d0e751bb59b3fb58bec003233f7e5e3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36e84700f75b": { "name": "terminal.focus#1", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json index c567b3dff9a..3163e1f5b00 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "1b3da0207aef65f4b2348aed334284259170c640e767fb354b9e95f3c1369445", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3e15def58214": { "activeHandle": "terminal-1", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json index f1ad9234d97..6ae07d2388c 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "fe83a7d50d08f874d863eb8872bcb24d974c1dc46571a93d3f9184f8feba63a4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1195cdf08e57": { "name": "unsubscribe-terminal", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index f513fa07b59..783d3dc7983 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "194b010d00fdeca85418870fd053ac500c38cae02e98c4bfc544b8fea78bbcb8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1195cdf08e57": { "name": "unsubscribe-terminal", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index d68d1c2fe1e..11df5d7162f 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close-session", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "334b50e135103db21b93e60acff31047823371049171b57388df050179a6dcbc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1195cdf08e57": { "name": "unsubscribe-terminal", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index 9c38aa6d43e..5b6d1d22365 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-close", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "9e877af8539e5425f65edd6b3ff8af73e719aae2033980411a8e8a3b508dcd9e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "056772c9ebd6": { "name": "terminal.rename#1", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index ff8aab7dd9e..dee5ca5775c 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tab-close", "family": "session.tab-rename", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", - "scenarioSha256": "927e7cda255922c2e6ac893f0068679774ce5de988376df1ff8b7d5072f7d127", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "056772c9ebd6": { "name": "terminal.rename#1", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index 592720b6ebf..05337a4c387 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tabs-stream-health", "family": "session.tabs-stream-health", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "4f55a21a5c42ff8d96ccb1b16de235f91f9f7e38fe90de7191c36c8c16cc4e43", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0cde28482d1a": { "name": "session.tabs.list#1", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index 43513fd5d5f..411acd5125b 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tabs-stream-health", "family": "session.tabs-stream-health", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "29bee268df8e92fb1e3fd59291262c8d2d04a1b7e9fe40f6ed8dd181b0df828a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0cde28482d1a": { "name": "session.tabs.list#1", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index 646aeedd242..d2575205a91 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tabs-stream-health", "family": "session.tabs-stream-health", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "101e8ce865088891800680db0e3df787b5f15ceb05ace9840931c384d9732d1c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0cde28482d1a": { "name": "session.tabs.list#1", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json index 1b38eea06ba..dd843891aec 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.tabs-stream-health", "family": "session.tabs-stream-health", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", - "scenarioSha256": "5381b6ade796596ac362d4ed64349266e65fe8fd2b4fc778a54315d4b6fda3da", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0cde28482d1a": { "name": "session.tabs.list#1", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json index eb941532060..a0693d0d4c2 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-display-mode", "family": "session.terminal-display-mode", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", - "scenarioSha256": "a4ba13014a128c0e06a3d40b9e3fa3b0136b07f52a92c89a9afa3c73bc39d69f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "39066dc373ed": { "name": "terminal.setDisplayMode#1", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json index 22c345648ec..09e2abe41fb 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-display-mode", "family": "session.terminal-display-mode", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", - "scenarioSha256": "10295c4779f010c5040a7a04264a8094c342752ea5d75d887ab99f1a57ba4b2e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3ba74271a870": { "name": "terminal.setDisplayMode#1", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json index b2ed727567b..f94c96e44e9 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-display-mode", "family": "session.terminal-display-mode", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", - "scenarioSha256": "bb278c4da53b82d84cf50b308901ce196cc1f07c1a84790ad491319ee6746541", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36a9812a9d80": { "crash": { diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json index 9d8e0ca1fda..94db0ea89fd 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-display-mode", "family": "session.terminal-display-mode", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", - "scenarioSha256": "16f645f758f2463bf6e16559d581afbc8794aa56069210f13a45952158d35a4c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4de04942fad9": { "name": "terminal.setDisplayMode#2", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json index 3bec41d3d14..5ba97b73847 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-display-mode", "family": "session.terminal-display-mode", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", - "scenarioSha256": "fc81b0ac28970f9db27276c90f486306d74387f1404d757216a09823fedf680c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "61b1eb90a019": { "crash": { diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json index 819c744595e..96b547cbb6c 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-inventory", "family": "session.terminal-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "d0f5ecc9c8fcb10193f481648215a54460ce5a54a8fbd2ded3921a9599fefc0a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "34f7b35ae07c": { "name": "terminal.list#1", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json index 68a8101eb70..4fceb97c324 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-inventory", "family": "session.terminal-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "5c2b3237a14f9df9357ab458b2e21f2df8e68ad6bf6dc5670c0ace7eeb567e80", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "34f7b35ae07c": { "name": "terminal.list#1", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index dd47dfd4a3f..c10597ab928 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-inventory", "family": "session.terminal-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "9f3fb6e58e8d9ef4f52b2b6c0a42b6e4285d5786060f966261795cf64d055f65", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "34f7b35ae07c": { "name": "terminal.list#1", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index 702e90b09d9..b88994ece43 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-inventory", "family": "session.terminal-inventory", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", - "scenarioSha256": "10a8ba5332f4fc2f90cff537ca69f2f1474339cbc4996f67a6feaea70669fb25", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "34f7b35ae07c": { "name": "terminal.list#1", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 10aa9bf6fad..0ae2887addc 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "4b4b4a8d1acaaec1c8dde0233dc49a696ffe53466578477efcbcdb7263dbd617", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4f53cda18c2b": [], "b0e514c7c334": { diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index cbc26766aba..a011278529a 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "cac4980465661fba372e187699741123a4edeb9270125a0a1cad7bbb6a6adebd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02740e15d2b5": { "name": "settings.get#2", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index 59f48eb7903..5e9886a0db9 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "f8ebd2348373b2b39c167735e0c418dfe868511fb5306ecba90cb6f2a905b95e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4f53cda18c2b": [], "b45a49bc8209": { diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index af9092a191a..eae00e2ae56 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.bot-overrides", "family": "settings.bot-overrides", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "f7e63144421a689f05cc50ad87ec901a9eaeb3163165656887be12a5f2753005", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "32b35430e320": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index bfd248c1111..0259a22c90f 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "d8d6f738ee11d84d6e9e546624f4babcb42476432f6bddbc74e8519d9ca18370", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a5b778e224a": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index 8559c33d6f8..26c38d5d119 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "a32b2fc99e830c58460e6f7c857aed0048738a55501e508eb236604680b9c235", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a5b778e224a": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index 2383aa11981..3efacf04a41 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "78c48025c4af6cc0f1f136448c0ede9f76b7485d7b33b1356d11dec017bd9053", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a5b778e224a": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index 09641d9d31f..09dfda92660 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "c22f33a0ed28622d4d53ae31e934056f87d2c10e8dc4475831ae1ee5fd3a9b8b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a5b778e224a": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index 7e0cfa9e465..2a5b310c0a1 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.home-providers", "family": "settings.home-providers", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "4f16b43dddfb9257828342b0297317868df24b03fd98a89c66f5cd1897829d73", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0a5b778e224a": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index 7678703497f..db0553d122d 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "b6fb40be3bb92d7d9f1a79d99dee077cf95097077917679dc99702f912241fa4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index 5a83681082c..42e6445e0b1 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "31f8a348322551738b14207b3477bae492d48d45c5d51be4d97ffaca2fe2b6e1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02188f091419": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index a14c158b06b..70858dc1625 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.new-tab-agents", "family": "settings-agent-read", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", - "scenarioSha256": "1347663aba0ada1eee8e88fac306757dc0d29fe21f0d062ac2d3968a30a2f214", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1c2564f1daca": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index eb4fc941aca..e9edf4df1a8 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "adebd553e2648278d719a1d7299cb36683fce714682a1ab7b49d4c9027eea34e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00271b9f4a55": { "name": "repo.list#2", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 48d11dcd47b..aabdbe23adb 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "b45eba2007e8e2668f524cd7503b8a711eba67816c9c35af5c3725a1afe32d8d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index f2d0e0648ed..48777a60761 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "e67b645d8c4fa42e93d4cf36f45311922f7b9d9d8cc597a12f55c664d840901b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "12ba3f41d4db": { "name": "hostPlatform", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index 534db6719a7..a458cc7adeb 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "b1c0b957b828c32e7ec388ec6668273fe84bbe5d11d8286b9a246fa92395a26e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 826fa175c36..0e850d65689 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "867b6905c8a533ddd1c7c8174bf4aadd5fd725cc72bdddbcb2ea8af26e219078", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 3ff00bc0066..f3d00fb95da 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "d8a00a72849f1ed254c3b35ebcc330dd1bb15b189f006bd1517853a19e53de6c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "19b2979d7fc2": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index b096dc378ef..3dec40ec45f 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.repo-metadata", "family": "settings.repo-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "741db13a84dbcec2e97e80605d742e69558954657c72f8450f3f8bc177dd01b6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03c9a2ba60b9": { "name": "host.platform#1", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index a3fde54d0e0..9bf60763b00 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "7a8d0a5305aafea56733c229989b6e825fe9b8a681f48f6cef405350304520b6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index 70edb4661de..e024407d2c9 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "a11756ecaf7c2d3955b9512aa7479ca55d810341f1492f472985abb538e140e8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "03251bdb225e": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index 3daae99cf55..aecd13f75f7 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "def0640be601a8013f9537f161b60d8c14ac9551931e5ee4d4cc2acd3c2baf2a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1bfeba989b20": { "name": "worktree.ps#1", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index 995ad34d24b..a379fa56c21 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.resume-metadata", "family": "settings.resume-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "17c31d0c2b7ae5322fd59bafcfa1d2779ae9eff841e12c0cf16b27e454b49f13", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1bfeba989b20": { "name": "worktree.ps#1", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index 9940a7b242c..0c603a17d8f 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "c08cce5d1f71761dbf504863b736e5546abb42b9ff4ab8ced65c7c42e3d66c0e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index a2353d9cd01..88ca6b89933 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "b23c3076081901c89e8a8fb8d20028e03f030db040c9cd793b6f2c7cd49d8f25", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index ef29c952cfc..b2bbcbeda98 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "a510ff7505cddbd6dad3c7e5a2dcde206a5dab1940901511d72c97aca576a6f1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index b03484b95bc..b65c48ad0f5 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-hydration", "family": "settings.task-hydration", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "d4a2fef3aefb78bdb4aed94fda982124f24a3af3832227654d324735f44aaeeb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01236e463cd4": { "name": "linearStatusPickerItem", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index 9331d8c3eb4..4eb99fdf724 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace-create", "family": "settings.task-workspace-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "662c3e04e31bce5757f09f91e3e3739fb9d57767b7443be4dc936705b64b1432", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e0c27425410": { "name": "navigation", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index 9088f5d3b1e..b7b7504a771 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace-create", "family": "settings.task-workspace-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "8ae9e1dbb32d404eac9e01f71dacf1c37497030220a8e988c0093bb7ed2d159b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "037515630624": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index 703829be684..20a9306b76e 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace", "family": "settings.task-workspace", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "5c4c890e4c71e80fa8847a5e29700fc9df3ac3bd634bad6289db37522fadd621", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1686352a2bbd": { "name": "error", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index 5e433195d9a..ee6d6a8339b 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace", "family": "settings.task-workspace", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "a699a0a5b128fa422dab0c7557b5aa18599b2d23fa6685cdcc02e17edf328af1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1a4a4adee14f": { "name": "creatingKey", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index 1575042ffff..62bceef74bc 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-workspace", "family": "settings.task-workspace", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "a5e812cd508826b3f01ec3798c621ab4303de6536a364113f01a4770dd197bb5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1a4a4adee14f": { "name": "creatingKey", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index c0aa3ba1b23..43023fce813 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -1,27 +1,18 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-preferences", "family": "settings-best-effort", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "bbcdefe16b07068a81f3c46ae60df01ccb0fbe5a7c1eade3f584f6f0130c23fe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "5957e7c52ab6": { + "03d1fef54a8c": { "name": "settings.update#1", "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.update\",\"params\":{\"defaultTaskViewPreset\":\"assigned\"}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.update\",\"params\":{\"defaultTaskViewPreset\":\"my-issues\"}}" }, - "5db909d58c6f": { - "preset": "assigned" + "38f60ac30222": { + "preset": "my-issues" }, - "7d44d50e0ec6": { + "687f86875a7f": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -32,7 +23,7 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" } }, { @@ -53,12 +44,12 @@ } } }, - "ad91b485db0c": { + "a10aec8208b3": { "name": "defaultGitHubPreset", "ordinal": 1, - "value": "assigned" + "value": "my-issues" }, - "bbe37721bfbe": { + "b6c9da6aafa0": { "name": "settings.update#1", "ordinal": 2, "args": [ @@ -69,7 +60,7 @@ { "name": "params", "value": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" } }, { @@ -99,27 +90,27 @@ { "id": "optimistic", "observation": { - "sender": ["bbe37721bfbe"], - "payloads": ["5957e7c52ab6"], + "sender": ["b6c9da6aafa0"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } }, { "id": "settled", "observation": { - "sender": ["7d44d50e0ec6"], - "payloads": ["5957e7c52ab6"], + "sender": ["687f86875a7f"], + "payloads": ["03d1fef54a8c"], "settlements": { "mount": "eb79a9b3682a", "write": "eb79a9b3682a" }, - "state": "5db909d58c6f", - "effects": ["ad91b485db0c"] + "state": "38f60ac30222", + "effects": ["a10aec8208b3"] } } ] diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index 647269388f2..477f06f8621 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "287f94e469548f28c9d5591ff6ffb916fa22caa18776b091542b75704c9e1fee", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e7f9a45facb": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index 5dad315f94d..970c6a41815 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "76793a56e7b9e596d8c42e9a5a1c47337db32d7437bec2e41d6e7253943f3fd8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1b3e4be6609f": { "name": "settings.get#2", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index 5abf97f8206..2249efff330 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "ad19fc24973b49ee7d14bc31460a7e4af5d207db6a2375b52b5a0aa878e09205", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e7f9a45facb": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index d7262a8d933..b815b0616c2 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-context", "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", - "scenarioSha256": "a2c80c9cdbb631f3a8fa648dfbb9e691418467d6ead8fe769d72e7e1d8b552b4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1e7f9a45facb": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index c14e5708c0c..6dc3f9490c3 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-submit", "family": "settings.workspace-submit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "b10ff86086c134284cb0446e8857cd4b55f5ff2bd0507388ec659a95f25e2a19", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13c996e4ec2b": { "creating": true, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index 37a2b5ef9cb..f5459aab8ed 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-submit", "family": "settings.workspace-submit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "373ea3743dac4e0845df01d5c8f75c909563b8c517f3858293a478234dc9ca5c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13c996e4ec2b": { "creating": true, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index 5d7cc485d6b..20fec5784e9 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.workspace-submit", "family": "settings.workspace-submit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", - "scenarioSha256": "dfbacbd6392ae8e8199550952fe917e7c01182349df6c99a06eb0682cfd9175c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "13c996e4ec2b": { "creating": true, diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index 6a67d75a0f4..42b1624f28b 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.audio-chunk", "family": "speech.dictation-chunk", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "08b583790d858a4cb7cf7377126818b6d05766c02587343ec89a167f94094082", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02cebe6f0062": { "failures": [], diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index 67ed20e3710..9aabbf4419c 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.desktop-start", "family": "speech.dictation-start", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "35fadd03f2c98344e22d6aec2dc85ad15ca5ac8e092fc1c29a20366db5d46628", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3b5708754539": { "name": "speech.dictation.start#1", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json index 834ee80e8e1..ca5159beb86 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.desktop-start", "family": "speech.dictation-start", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "75b8d1b7ed98b2bf7420986958d0a36222b007c535f85c1308b534301241ec2d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1cf8d4be517f": { "activeId": { diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index b95b912f28e..ec8e3ea12c4 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.desktop-start", "family": "speech.dictation-start", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "b1c4a6d6c94d54fb5f60eb2deb437562ededd724140dd3973d842d7c29bf1a60", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3b5708754539": { "name": "speech.dictation.start#1", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index 7f0ff48fca1..7db1cd929bb 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.dictation-session", "family": "speech.dictation-session", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "5fe007d384731f954e086c16886055722fab45bafac23f4c4d52f0bb1f47acc9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "422e902e66a5": { "name": "speech.dictation.cancel#1", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index 7f5f2e8ec5d..fe249575716 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.dictation-session", "family": "speech.dictation-session", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "cd3be6291ce181c4acfd7bacc0a8380f296fa5db13a52c4c0fcd75cfff3ae7d6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1bdc94d60271": { "name": "speech.dictation.finish#1", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json index 76559248ae9..479b08be79d 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "69c1c20ea667a2b6a5b53aeb3d9af11b2b707e04086e15ee4ff9e954b1701851", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "100447f8b483": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index 2148cd20762..8a0e5e99a5c 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "748e1bc0575fca6baab51b19cbbea5ac6185fca2a15dc4d8577212134355cd7e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0e61f11d0307": { "name": "speech.models.delete#1", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json index 73973586c8b..53045b2042c 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "0ba2d8283fe98206c7500b62732e30acdc5b8e55f50b7ea8cae96403b803d519", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "100447f8b483": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json index 224c6e49d73..b0f3cd54c7c 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "speech.setup-sheet", "family": "speech.setup-sheet", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", - "scenarioSha256": "88e563aa087955b9ef6447b7e73711f90d5befb7c082270da58695c3a7169e67", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "430a60866d83": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index dd1bf6e99a5..c341f2230c2 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "6e2d1633645a3072241729328cc71ea2979533d28797c14de86c8b3457ac018a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "568aa5cfc6b5": { "name": "agentSession.createSupport#1", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index bf2a2e0f42e..27835a48c8e 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "0b5dd55868490e4d62d7d718821dec9634773b20d32fe9889523606a3f6b9168", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "568aa5cfc6b5": { "name": "agentSession.createSupport#1", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index 0d19d85ec3b..3590e29b1fe 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "64916f2d8ab51132b08c3e8c72f89c3a2698d83945def294f42edf22eb6d08ea", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1cd1542a5d9c": { "name": "agentSession.create#1", diff --git a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json index 2902789d8c9..7f19430ef07 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "fb1ab1209f0a7c03ee1b3985401ce2df080d673532f045c4fca26e754d5e5a9f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "32c13d52ae1c": { "name": "agentSession.create#1", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index e73320d67e3..f2bf93c40cd 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "d8928461e3295d265872e5f98fb8aad445b5edc55fc76f137e45c0cff0d8b961", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2c227fd1941f": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index 1a87bc4c4ff..6cbff36c24b 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "agentSession.structured-launch", "family": "agentSession.structured-launch", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", - "scenarioSha256": "c30e9cae49e134715c009a98f423f3e4a9d552c014be3e28b38e98c57999b6f5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "551313dcc738": { "launched": { diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index 290bf880385..ee7dc80804a 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.route-repo-list", "family": "tasks.route-repo-list", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", - "scenarioSha256": "b62ca571d4defcc2e53960033c5b4eb3f7e406b57664664cbc6a173041a9f803", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "19b2979d7fc2": { "name": "repo.list#1", diff --git a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json index 2029b150b96..152ff6c6ccc 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-gesture-input", "family": "session.terminal-gesture-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", - "scenarioSha256": "97e6d63c6b4bc154e94be6cf3dcd25620b4656cacd2b62cdc872ff793b39ebd2", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0cc3e25ebff5": { "bucketTokens": 63, diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index 90d2e5d7b69..dec74bc297e 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-input-send", "family": "session.terminal-input-send", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "7cdbf3308411ed6764cc1cdcc0f663a27ddc9390fcc329c49fad4b27cb5a7fd5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ba725456703": { "crash": { diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index fc3cbf9ceb1..1094c006e73 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-input-send", "family": "session.terminal-input-send", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "eb5e47e3accccc7ca280ca029f97405905b0cee8a05afb9ef15448314041d9d4", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5e9826c92f7b": { "crash": { diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index e4cf7f75331..092ea735a97 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-input-send", "family": "session.terminal-input-send", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "391a4f681443f099e6ea4417811d82d730242dfe07e21f74b0ad49a98bcd7c81", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "49c62bc1232e": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index a11282a2378..2b5d50d1cd9 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.terminal-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "48bb495de3b54f2b2edc0543f0f232ff4fd6068d5aca34d005766ebacbb078c9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "26d0fe750677": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index 6ee741a85e6..22e8a47caca 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.terminal-paste", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "78e62b9125252accc7f6d2ce772b93c84a917b01d2df308c1f9fb163d9127665", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "10a954319977": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index af4b89b745e..0b3d1473c04 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.query-reply", "family": "terminal.query-reply", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "bfa1d5f83b4112d3cce6a19e9dc27daf9281ed99745bc01bd19226dd7b268c71", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11a49f853eb8": { "accepted": true diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index 2a4d6c75152..48e4baf831b 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.query-reply", "family": "terminal.query-reply", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "ad03596f31eeccc5e9eb7e5061b1af705f9af249f76377c8f5ce1a9db257770f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7ed3d39f0607": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index c8ec6a1c4c1..dc54132fff8 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.accessory-raw-send", "family": "terminal.raw-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "76fc0c1499ec48a67428f35eba705f68147d2ff2c344b67aa0c9d60ed999f173", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "48932570bb0e": { "name": "terminal.send#1", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index daf232e0867..1cd0247ebc4 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.accessory-raw-send", "family": "terminal.raw-input", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "579077efd3a4652a6930af3f6690138c20536270cbcd7274246047d2e322199f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11a49f853eb8": { "accepted": true diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index cd7ab9ab2df..11d760cbf34 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.takeover-report", "family": "terminal.takeover-report", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "c2236257032fab72ebb007391313eec4e5f0a1bdb4fbcbd907117f9914ffafc6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "30ac06d94f6f": { "name": "orchestration.workerTerminalUserInput#1", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index 059ef635246..f4d2214e22f 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.takeover-report", "family": "terminal.takeover-report", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "8ad436ca6c3de8b0b3148326337acce18a8a4cf3c514acad1a4530a001f28c75", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "44136fa355b3": {}, "7a48f3d49553": { diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index 581b27eb031..83af7725d25 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.viewport-refit", "family": "terminal.viewport-refit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "f966c2ef2e747a5231f423147ddaf38458cb08c719434b274016a5e4abc10771", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "685125182eef": { "name": "reflow", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json index c60bedf21b2..a1120a9c099 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "terminal.viewport-refit", "family": "terminal.viewport-refit", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", - "scenarioSha256": "88961a369cb7a7fa92708bb83aa7f818e904018e8cfcedc2f50ef9a0058c8b9d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "7e0619ad636f": { "measured": true, diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index 73dda3c865b..3e7b855496d 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "session.terminal-clipboard", "family": "session.worktree-connection", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", - "scenarioSha256": "f921c4a764cdf7a4c1df0a7ec618d7c3b1d8a05ee4baa42d66f3bc0d95b3f550", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3fb0e93ab6cb": { "name": "settings.get#1", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index 97b68c2b786..39f6c96b51e 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-github", "family": "tasks.task-create-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "260a84280f69c43ac582149d9befe6ae547b2ad2b5f56a181d8e3a1314a0a071", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fef9dc7845d": { "name": "createTitle", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index 9e8c31c0a14..cfe3ffd7b23 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-gitlab", "family": "tasks.task-create-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "a4d3d1f322d49593056bd20f4122bbf0309d2161123e404161950bcab65decd5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fef9dc7845d": { "name": "createTitle", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index cbe96ac449d..36978b8e800 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-create-linear", "family": "tasks.task-create-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "f6c7eea985190d9edeead5b36f90c8aa98679f69d19dab7579fec88841645259", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0fef9dc7845d": { "name": "createTitle", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index 4cdfd0c51af..c418584f8c0 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-checks-files-github", "family": "tasks.item-checks-files", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "644a27c1e3fa6bfa73c7822c2949e9534508e5e3a996b0e8e56be6326658d123", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "01a8f1e0db1b": { "name": "github.rerunPRChecks#1", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index 2e0bdd7a356..2fa6daec4fc 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-comment-github", "family": "tasks.item-comment-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "fb0cdff9e02bac37b0bd8e1c47922474823d46fa735f3069ed70cdad41800be6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "064357f4a198": { "name": "detailPayload", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json index 058313a2328..1ef19632afb 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -1,73 +1,15 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-comment-gitlab-mr", "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "cb696d402b8af2b9a54d4d6f9d85abfc12280e73b360196e55ec25530e610eee", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "14949c71727e": { "name": "mutatingStatus", "ordinal": 7, "value": false }, - "59b03dcdb55e": { - "name": "itemCommentDraft", - "ordinal": 5, - "value": "" - }, - "63b426badd37": { - "name": "gitlab.addMRComment#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addMRComment\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"body\":\"a comment\",\"projectRef\":\"group/project\"}}" - }, - "6c49f5e0f2ca": { - "draft": "", - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": [], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "mr" - }, - "title": "A merge request" - }, - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - }, - { - "author": "You", - "body": "a comment", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": 905 - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "de38fe098abd": { + "48f9793316e8": { "name": "gitlab.addMRComment#1", "ordinal": 3, "args": [ @@ -80,7 +22,10 @@ "value": { "body": "a comment", "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -110,6 +55,58 @@ } } }, + "59b03dcdb55e": { + "name": "itemCommentDraft", + "ordinal": 5, + "value": "" + }, + "aff818772586": { + "name": "gitlab.addMRComment#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addMRComment\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"body\":\"a comment\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "ea24a662dd3d": { + "draft": "", + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": [], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "mr" + }, + "title": "A merge request" + }, + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + }, + { + "author": "You", + "body": "a comment", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": 905 + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -160,13 +157,13 @@ { "id": "comment-settled", "observation": { - "sender": ["de38fe098abd"], - "payloads": ["63b426badd37"], + "sender": ["48f9793316e8"], + "payloads": ["aff818772586"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "6c49f5e0f2ca", + "state": "ea24a662dd3d", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index 3ebefe16c04..e1461fa8aaf 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -1,24 +1,15 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-comment-gitlab", "family": "tasks.item-comment-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "d993b9f74cf8c5d7af8d31a73cf19d97141c54f6fcf009e98ca5b9106f3ba35b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "14949c71727e": { "name": "mutatingStatus", "ordinal": 7, "value": false }, - "48a9b4deaa5b": { + "17b97af91ff8": { "draft": "", "error": "", "item": { @@ -27,7 +18,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -87,7 +81,20 @@ "provider": "gitlab" } }, - "6d3492ac42bc": { + "8ab635fff625": { + "name": "gitlab.addIssueComment#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addIssueComment\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"body\":\"a comment\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ebbbaa5649f1": { "name": "gitlab.addIssueComment#1", "ordinal": 3, "args": [ @@ -100,7 +107,10 @@ "value": { "body": "a comment", "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -130,19 +140,6 @@ } } }, - "bc28ca26b0ad": { - "name": "gitlab.addIssueComment#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.addIssueComment\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"body\":\"a comment\",\"projectRef\":\"group/project\"}}" - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ed3a7d6bc894": { "name": "error", "ordinal": 2, @@ -160,13 +157,13 @@ { "id": "comment-settled", "observation": { - "sender": ["6d3492ac42bc"], - "payloads": ["bc28ca26b0ad"], + "sender": ["ebbbaa5649f1"], + "payloads": ["8ab635fff625"], "settlements": { "mount": "eb79a9b3682a", "comment-0": "eb79a9b3682a" }, - "state": "48a9b4deaa5b", + "state": "17b97af91ff8", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json index 3e5c3a69a39..90908cd9534 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-github", "family": "tasks.item-detail-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "885dafb68162f5777fc050af34dbe3443940d041a45bfb18c51dfcf433ed54f3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "19e10d5e8599": { "error": "", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index 2cf15710591..73d14ed488e 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-github", "family": "tasks.item-detail-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "5fbff075d7da476f93c2a7da871c2afacc67822d1317acf6c23000195e2b2576", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1874e6e64ab8": { "error": "", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json index 1e93f61396b..b384a848a1b 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -1,21 +1,13 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-gitlab", "family": "tasks.item-detail-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "c49d2df6c31f37c488ce9d29a801dd8ad0aa668ae380fc60b1f790ef508281d8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "04f934425ed7": { - "error": "", - "item": { + "012013790766": { + "name": "actionItem", + "ordinal": 7, + "value": { "provider": "gitlab", "source": { "checksSummary": { @@ -30,7 +22,10 @@ "labels": ["bug"], "mergeable": "MERGEABLE", "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "reviewDecision": "approved", "reviewerCount": 0, @@ -38,62 +33,12 @@ "type": "issue" }, "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "authorAvatarUrl": "", - "body": "a discussion note", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": 901, - "reactions": [ - { - "count": 2, - "name": "thumbsup" - } - ], - "url": "https://gitlab.example.com/group/project/-/issues/4#note_901" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" } }, - "1dc35b955ad0": { + "20dbc6200418": { "name": "gitlab.workItemDetails#1", "ordinal": 5, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemDetails\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":4,\"type\":\"issue\",\"projectRef\":\"group/project\"}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemDetails\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":4,\"type\":\"issue\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" }, "264010756b38": { "name": "detailLoading", @@ -107,7 +52,7 @@ "$rpc": "null" } }, - "5737a5c49d39": { + "59623b0af407": { "name": "gitlab.workItemDetails#1", "ordinal": 4, "args": [ @@ -119,7 +64,10 @@ "name": "params", "value": { "iid": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "type": "issue" } @@ -171,10 +119,52 @@ } } }, - "5a29c0f1892b": { - "name": "actionItem", - "ordinal": 7, - "value": { + "612bc240a5da": { + "name": "items", + "ordinal": 8, + "value": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ] + }, + "8ccd4fa759a5": { + "name": "detailLoading", + "ordinal": 9, + "value": false + }, + "a4f1a8696a24": { + "name": "detailError", + "ordinal": 2, + "value": "" + }, + "d5ef076aa5ea": { + "error": "", + "item": { "provider": "gitlab", "source": { "checksSummary": { @@ -189,7 +179,10 @@ "labels": ["bug"], "mergeable": "MERGEABLE", "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "reviewDecision": "approved", "reviewerCount": 0, @@ -197,18 +190,61 @@ "type": "issue" }, "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "authorAvatarUrl": "", + "body": "a discussion note", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": 901, + "reactions": [ + { + "count": 2, + "name": "thumbsup" + } + ], + "url": "https://gitlab.example.com/group/project/-/issues/4#note_901" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" } }, - "8ccd4fa759a5": { - "name": "detailLoading", - "ordinal": 9, - "value": false - }, - "a4f1a8696a24": { - "name": "detailError", - "ordinal": 2, - "value": "" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -243,36 +279,6 @@ "pipelineJobs": [], "provider": "gitlab" } - }, - "fa9ae39d069e": { - "name": "items", - "ordinal": 8, - "value": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ] } }, "recording": { @@ -281,19 +287,19 @@ { "id": "mounted", "observation": { - "sender": ["5737a5c49d39"], - "payloads": ["1dc35b955ad0"], + "sender": ["59623b0af407"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "04f934425ed7", + "state": "d5ef076aa5ea", "effects": [ "33614c90b6eb", "a4f1a8696a24", "264010756b38", "f960b61daf75", - "5a29c0f1892b", - "fa9ae39d069e", + "012013790766", + "612bc240a5da", "8ccd4fa759a5" ] } diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index 6ecbce878a2..a30fe3c549f 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -1,19 +1,181 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-gitlab", "family": "tasks.item-detail-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "36bbd34ed8b8f67e516ce7cd230b01ab88f14369fda632037c46dbbd1a0d95a3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "17a248d44317": { + "012013790766": { + "name": "actionItem", + "ordinal": 7, + "value": { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + }, + "20dbc6200418": { + "name": "gitlab.workItemDetails#1", + "ordinal": 5, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemDetails\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":4,\"type\":\"issue\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "264010756b38": { + "name": "detailLoading", + "ordinal": 3, + "value": true + }, + "33614c90b6eb": { + "name": "detailPayload", + "ordinal": 1, + "value": { + "$rpc": "null" + } + }, + "612bc240a5da": { + "name": "items", + "ordinal": 8, + "value": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ] + }, + "6a8913577681": { + "name": "detailPayload", + "ordinal": 6, + "value": { + "assignees": [], + "body": "body", + "comments": [], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "8ccd4fa759a5": { + "name": "detailLoading", + "ordinal": 9, + "value": false + }, + "a4f1a8696a24": { + "name": "detailError", + "ordinal": 2, + "value": "" + }, + "cfffc75a3055": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "checksSummary": { + "failed": 0, + "neutral": 0, + "passed": 0, + "pending": 0, + "state": "none", + "total": 0 + }, + "id": "gitlab:issue:4", + "labels": ["bug"], + "mergeable": "MERGEABLE", + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "reviewDecision": "approved", + "reviewerCount": 0, + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "loading": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "e9b468d599e5": { "name": "gitlab.workItemDetails#1", "ordinal": 4, "args": [ @@ -25,7 +187,10 @@ "name": "params", "value": { "iid": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "type": "issue" } @@ -62,73 +227,6 @@ } } }, - "1dc35b955ad0": { - "name": "gitlab.workItemDetails#1", - "ordinal": 5, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemDetails\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":4,\"type\":\"issue\",\"projectRef\":\"group/project\"}}" - }, - "264010756b38": { - "name": "detailLoading", - "ordinal": 3, - "value": true - }, - "33614c90b6eb": { - "name": "detailPayload", - "ordinal": 1, - "value": { - "$rpc": "null" - } - }, - "5a29c0f1892b": { - "name": "actionItem", - "ordinal": 7, - "value": { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - }, - "6a8913577681": { - "name": "detailPayload", - "ordinal": 6, - "value": { - "assignees": [], - "body": "body", - "comments": [], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "8ccd4fa759a5": { - "name": "detailLoading", - "ordinal": 9, - "value": false - }, - "a4f1a8696a24": { - "name": "detailError", - "ordinal": 2, - "value": "" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -136,98 +234,6 @@ "value": { "$rpc": "undefined" } - }, - "f2d8814a60b2": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "loading": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "fa9ae39d069e": { - "name": "items", - "ordinal": 8, - "value": [ - { - "provider": "gitlab", - "source": { - "checksSummary": { - "failed": 0, - "neutral": 0, - "passed": 0, - "pending": 0, - "state": "none", - "total": 0 - }, - "id": "gitlab:issue:4", - "labels": ["bug"], - "mergeable": "MERGEABLE", - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "reviewDecision": "approved", - "reviewerCount": 0, - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ] } }, "recording": { @@ -236,19 +242,19 @@ { "id": "mounted", "observation": { - "sender": ["17a248d44317"], - "payloads": ["1dc35b955ad0"], + "sender": ["e9b468d599e5"], + "payloads": ["20dbc6200418"], "settlements": { "mount": "eb79a9b3682a" }, - "state": "f2d8814a60b2", + "state": "cfffc75a3055", "effects": [ "33614c90b6eb", "a4f1a8696a24", "264010756b38", "6a8913577681", - "5a29c0f1892b", - "fa9ae39d069e", + "012013790766", + "612bc240a5da", "8ccd4fa759a5" ] } diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 95534639bb0..a17e89dce41 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-linear", "family": "tasks.item-detail-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", - "scenarioSha256": "f0407adc774b29553bdd177885e8ea9047127c4ebe8298de160a421fb4c1fe67", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1eaad537f925": { "name": "linear.getIssue#1", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index 44881318422..6964c40ccfc 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-detail-metadata", "family": "tasks.item-detail-metadata", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", - "scenarioSha256": "e6429c235d8c0b0376f71fea7b47c3b9ff22b850c92922b85ff993ae8b6cd6d0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "187a6bd82efe": { "labels": ["bug", "chore"], diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index 9f8de155696..46d6208c286 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -1,26 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-merge-gitlab", "family": "tasks.item-merge-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "28d8dfb15fec1ecaaa1c675c9c8c0cdc196e184a6884625c44dfe1f0f9fc8e7e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "3ea7248d382b": { - "name": "actionItem", - "ordinal": 5, - "value": { - "$rpc": "null" - } - }, - "af959aaccf63": { + "3272022b3a4e": { "name": "gitlab.mergeMR#1", "ordinal": 3, "args": [ @@ -33,7 +17,10 @@ "value": { "iid": 7, "method": "squash", - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1" } }, @@ -57,7 +44,14 @@ } } }, - "b6a6630b4d40": { + "3ea7248d382b": { + "name": "actionItem", + "ordinal": 5, + "value": { + "$rpc": "null" + } + }, + "589373883844": { "error": "", "item": { "$rpc": "null" @@ -69,7 +63,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -94,10 +91,10 @@ "provider": "gitlab" } }, - "c6efd8599d7e": { + "af6d48e640c6": { "name": "gitlab.mergeMR#1", "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.mergeMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"method\":\"squash\",\"projectRef\":\"group/project\"}}" + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.mergeMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"method\":\"squash\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -129,13 +126,13 @@ { "id": "merge-settled", "observation": { - "sender": ["af959aaccf63"], - "payloads": ["c6efd8599d7e"], + "sender": ["3272022b3a4e"], + "payloads": ["af6d48e640c6"], "settlements": { "mount": "eb79a9b3682a", "merge-0": "eb79a9b3682a" }, - "state": "b6a6630b4d40", + "state": "589373883844", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index b6da88a9d85..fbdf91030dd 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-metadata-github", "family": "tasks.item-metadata-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "010c65eaa056c5df0b867dd5b5851e206e8479e9fcce02515f1e326df4b8889c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "4227d4e27287": { "name": "github.updatePR#1", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json index ea380eb5a05..106c7ce0fa8 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -1,140 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-metadata-gitlab-mr", "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "daa54da6cfb8d96c2a66c138beeaf70c764f96a60bdc84f2ff4cde80483cb365", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "231f5648a833": { - "name": "gitlab.updateMR#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"projectRef\":\"group/project\",\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]}}}" - }, - "510654109df9": { - "name": "items", - "ordinal": 6, - "value": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - } - ] - }, - "5c50471cdd48": { - "name": "mutatingStatus", - "ordinal": 10, - "value": false - }, - "6b479af80b3b": { - "name": "actionItem", - "ordinal": 5, - "value": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - } - }, - "b4055ff60394": { - "name": "itemAddLabelsDraft", - "ordinal": 8, - "value": "" - }, - "d03b2863c41e": { - "error": "", - "item": { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:mr:7", - "labels": ["bug", "triage"], - "number": 7, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "mr" - }, - "title": "Renamed" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug", "triage"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "d6b74d32804d": { - "name": "detailPayload", - "ordinal": 7, - "value": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug", "triage"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, - "d98b736ab220": { + "1baaae5c9ed0": { "name": "gitlab.updateMR#1", "ordinal": 3, "args": [ @@ -146,7 +16,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -180,6 +53,139 @@ } } }, + "266686bfdeec": { + "error": "", + "item": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug", "triage"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "2f03f52f6db7": { + "name": "actionItem", + "ordinal": 5, + "value": { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + } + }, + "5c50471cdd48": { + "name": "mutatingStatus", + "ordinal": 10, + "value": false + }, + "9e163f6ab338": { + "name": "items", + "ordinal": 6, + "value": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:mr:7", + "labels": ["bug", "triage"], + "number": 7, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "mr" + }, + "title": "Renamed" + } + ] + }, + "b4055ff60394": { + "name": "itemAddLabelsDraft", + "ordinal": 8, + "value": "" + }, + "d6b74d32804d": { + "name": "detailPayload", + "ordinal": 7, + "value": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug", "triage"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, + "db0f3547ca00": { + "name": "gitlab.updateMR#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMR\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"},\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]}}}" + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -210,18 +216,18 @@ { "id": "update-gitlab-settled", "observation": { - "sender": ["d98b736ab220"], - "payloads": ["231f5648a833"], + "sender": ["1baaae5c9ed0"], + "payloads": ["db0f3547ca00"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "d03b2863c41e", + "state": "266686bfdeec", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", - "6b479af80b3b", - "510654109df9", + "2f03f52f6db7", + "9e163f6ab338", "d6b74d32804d", "b4055ff60394", "f11c308f1979", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index 810e824ceec..7c2d7633138 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -1,19 +1,10 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-metadata-gitlab", "family": "tasks.item-metadata-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "4daf374f040e27836c154245d6a7fbad9ba3f6e6c0c9608218451286e4712d6b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { - "06ebfa394e4c": { + "1607ea6e021e": { "error": "", "item": { "provider": "gitlab", @@ -21,7 +12,10 @@ "id": "gitlab:issue:4", "labels": ["bug", "triage"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "title": "Renamed", @@ -36,7 +30,10 @@ "id": "gitlab:issue:4", "labels": ["bug", "triage"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "title": "Renamed", @@ -67,17 +64,7 @@ "ordinal": 11, "value": "" }, - "885570183416": { - "name": "gitlab.updateIssue#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]},\"projectRef\":\"group/project\"}}" - }, - "9de8a1be3f13": { - "name": "mutatingStatus", - "ordinal": 12, - "value": false - }, - "a372249e4dd9": { + "6d09f297c6ee": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -89,7 +76,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -117,12 +107,22 @@ } } }, + "9de8a1be3f13": { + "name": "mutatingStatus", + "ordinal": 12, + "value": false + }, "b4055ff60394": { "name": "itemAddLabelsDraft", "ordinal": 8, "value": "" }, - "cbb029dd26e9": { + "c217dd32a83b": { + "name": "gitlab.updateIssue#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"]},\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "c43f4fb1b61f": { "name": "actionItem", "ordinal": 5, "value": { @@ -131,7 +131,10 @@ "id": "gitlab:issue:4", "labels": ["bug", "triage"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "title": "Renamed", @@ -164,6 +167,29 @@ "provider": "gitlab" } }, + "e2912ad4d0f1": { + "name": "items", + "ordinal": 6, + "value": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug", "triage"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "title": "Renamed", + "type": "issue" + }, + "title": "Renamed" + } + ] + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -182,26 +208,6 @@ "ordinal": 9, "value": "" }, - "fb7a40f606c1": { - "name": "items", - "ordinal": 6, - "value": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug", "triage"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "title": "Renamed", - "type": "issue" - }, - "title": "Renamed" - } - ] - }, "fd2fad47bec2": { "name": "mutatingStatus", "ordinal": 1, @@ -214,18 +220,18 @@ { "id": "update-gitlab-settled", "observation": { - "sender": ["a372249e4dd9"], - "payloads": ["885570183416"], + "sender": ["6d09f297c6ee"], + "payloads": ["c217dd32a83b"], "settlements": { "mount": "eb79a9b3682a", "update-gitlab-0": "eb79a9b3682a" }, - "state": "06ebfa394e4c", + "state": "1607ea6e021e", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", - "cbb029dd26e9", - "fb7a40f606c1", + "c43f4fb1b61f", + "e2912ad4d0f1", "d6b74d32804d", "b4055ff60394", "f11c308f1979", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index c62f847e112..4f1dc4dc36c 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-reply-merge-github", "family": "tasks.item-reply-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "b8e60092a29ea4944a891adc026444cc1da18c52221876f9e7c07b450b34cea8", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f53bdc9dca": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index 4deb1f092d1..345587ed0a3 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-review-github", "family": "tasks.item-review-github", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", - "scenarioSha256": "3ba358ff7b6a95d9158257225483f77578dfa10c8643cdf89f8a81e0022997e9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0617d29f99ab": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json index b06650daa5a..bdd75551672 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-status-gitlab-mr", "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "5de4936aed0efd0d8bd5bc5ce893d561f6bc3d0fdf4a76c9e33e7cbefd6ec362", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3ea7248d382b": { "name": "actionItem", @@ -20,12 +11,7 @@ "$rpc": "null" } }, - "a768a7cc2f33": { - "name": "gitlab.updateMRState#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMRState\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"state\":\"closed\",\"projectRef\":\"group/project\"}}" - }, - "b6a6630b4d40": { + "589373883844": { "error": "", "item": { "$rpc": "null" @@ -37,7 +23,10 @@ "id": "gitlab:mr:7", "labels": [], "number": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "mr" @@ -62,7 +51,12 @@ "provider": "gitlab" } }, - "da71c86a0c81": { + "7c485a70bc3e": { + "name": "gitlab.updateMRState#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateMRState\",\"params\":{\"repo\":\"id:repo-1\",\"iid\":7,\"state\":\"closed\",\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, + "ab2e27397394": { "name": "gitlab.updateMRState#1", "ordinal": 3, "args": [ @@ -74,7 +68,10 @@ "name": "params", "value": { "iid": 7, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "state": "closed" } @@ -129,13 +126,13 @@ { "id": "gitlab-status-settled", "observation": { - "sender": ["da71c86a0c81"], - "payloads": ["a768a7cc2f33"], + "sender": ["ab2e27397394"], + "payloads": ["7c485a70bc3e"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "b6a6630b4d40", + "state": "589373883844", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } } diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index 4b85268372e..287b94974d9 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -1,29 +1,72 @@ { + "goldenFormatVersion": 6, "operation": "tasks.item-status-gitlab", "family": "tasks.item-status-gitlab", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", - "scenarioSha256": "85414544a9e8570567770b43e6bf5cfcc406c9b9b4ffed3a4ff0c8187beb7549", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04df4241c3b7": { "name": "mutatingStatus", "ordinal": 7, "value": true }, + "05df5d49a11c": { + "error": "", + "item": { + "$rpc": "null" + }, + "items": [ + { + "provider": "gitlab", + "source": { + "id": "gitlab:issue:4", + "labels": ["bug"], + "number": 4, + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, + "repoId": "repo-1", + "state": "opened", + "type": "issue" + }, + "title": "A GitLab issue" + } + ], + "mutating": false, + "payload": { + "assignees": [], + "body": "body", + "comments": [ + { + "author": "octocat", + "body": "a thought", + "createdAt": "2020-01-01T00:00:00.000Z", + "id": "comment-2" + } + ], + "labels": ["bug"], + "pipelineJobs": [], + "provider": "gitlab" + } + }, "104bf14d3af8": { "name": "error", "ordinal": 8, "value": "" }, - "18eece2e22ec": { + "20cf2c45839d": { + "name": "github.updateIssue#1", + "ordinal": 10, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" + }, + "20d4f63859b9": { + "name": "actionItem", + "ordinal": 11, + "value": { + "$rpc": "null" + } + }, + "2452bd6b82d1": { "name": "gitlab.updateIssue#1", "ordinal": 3, "args": [ @@ -35,7 +78,10 @@ "name": "params", "value": { "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -62,23 +108,6 @@ } } }, - "20cf2c45839d": { - "name": "github.updateIssue#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":9,\"updates\":{\"title\":\"Renamed\",\"addLabels\":[\"triage\"],\"removeLabels\":[\"bug\"]}}}" - }, - "20d4f63859b9": { - "name": "actionItem", - "ordinal": 11, - "value": { - "$rpc": "null" - } - }, - "263d60f9991d": { - "name": "gitlab.updateIssue#1", - "ordinal": 4, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"state\":\"closed\"},\"projectRef\":\"group/project\"}}" - }, "31052c726682": { "name": "itemRemoveLabelsDraft", "ordinal": 15, @@ -115,7 +144,7 @@ "ordinal": 14, "value": "" }, - "4f7c0c0ed605": { + "6463243364b2": { "name": "items", "ordinal": 12, "value": [ @@ -125,7 +154,10 @@ "id": "gitlab:issue:4", "labels": ["bug"], "number": 4, - "projectRef": "group/project", + "projectRef": { + "host": "gitlab.com", + "path": "group/project" + }, "repoId": "repo-1", "state": "opened", "type": "issue" @@ -134,6 +166,11 @@ } ] }, + "7c9ae1d5d0a9": { + "name": "gitlab.updateIssue#1", + "ordinal": 4, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.updateIssue\",\"params\":{\"repo\":\"id:repo-1\",\"number\":4,\"updates\":{\"state\":\"closed\"},\"projectRef\":{\"host\":\"gitlab.com\",\"path\":\"group/project\"}}}" + }, "832217477701": { "name": "itemRemoveAssigneesDraft", "ordinal": 17, @@ -144,43 +181,6 @@ "ordinal": 18, "value": false }, - "d9d32e421d46": { - "error": "", - "item": { - "$rpc": "null" - }, - "items": [ - { - "provider": "gitlab", - "source": { - "id": "gitlab:issue:4", - "labels": ["bug"], - "number": 4, - "projectRef": "group/project", - "repoId": "repo-1", - "state": "opened", - "type": "issue" - }, - "title": "A GitLab issue" - } - ], - "mutating": false, - "payload": { - "assignees": [], - "body": "body", - "comments": [ - { - "author": "octocat", - "body": "a thought", - "createdAt": "2020-01-01T00:00:00.000Z", - "id": "comment-2" - } - ], - "labels": ["bug"], - "pipelineJobs": [], - "provider": "gitlab" - } - }, "d9f85a74992e": { "name": "itemAddAssigneesDraft", "ordinal": 16, @@ -256,27 +256,27 @@ { "id": "gitlab-status-settled", "observation": { - "sender": ["18eece2e22ec"], - "payloads": ["263d60f9991d"], + "sender": ["2452bd6b82d1"], + "payloads": ["7c9ae1d5d0a9"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": ["fd2fad47bec2", "ed3a7d6bc894", "3ea7248d382b", "ecf9003e4ef1"] } }, { "id": "github-metadata-settled", "observation": { - "sender": ["18eece2e22ec", "fa0f8ddcd40a"], - "payloads": ["263d60f9991d", "20cf2c45839d"], + "sender": ["2452bd6b82d1", "fa0f8ddcd40a"], + "payloads": ["7c9ae1d5d0a9", "20cf2c45839d"], "settlements": { "mount": "eb79a9b3682a", "gitlab-status-0": "eb79a9b3682a", "github-metadata-1": "eb79a9b3682a" }, - "state": "d9d32e421d46", + "state": "05df5d49a11c", "effects": [ "fd2fad47bec2", "ed3a7d6bc894", @@ -285,7 +285,7 @@ "04df4241c3b7", "104bf14d3af8", "20d4f63859b9", - "4f7c0c0ed605", + "6463243364b2", "4521fe8a79e3", "49e6ea49243a", "31052c726682", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index 846d956b820..106d07abd39 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-connect", "family": "tasks.linear-connect", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "d1bd7ad9b647a50403953ba01d3a4bcccc5f4020c2aefa6146cca8c7688612c1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0d5ee6814ade": { "name": "linearConnectState", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index a53edae9599..8306fe5e947 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-item-actions", "family": "tasks.linear-item", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", - "scenarioSha256": "354eaff5243b3aae774c375aa303548d82c4db106a89ada39907d6654fa549b3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "04f53bdc9dca": { "name": "mutatingStatus", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index 3150b00e928..e373b812bcc 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.linear-team-context", "family": "tasks.linear-team-context", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", - "scenarioSha256": "524c2421e61d663dd1344a47ab0552c3bb029ad09e0b6271eac91f1548e8265c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "045de1beb41b": { "name": "prFileCommentDrafts", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index 9c91fd929cb..1f444499e0d 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-gitlab-items", "family": "tasks.task-list-gitlab-items", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "9b26b8f112021fe65c156b7d4067071551bcdab44a5858f8a933f7ace66e6f80", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2af5bdc42011": { "error": "", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 579f12b4e0c..3ed08d87664 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-gitlab-todos", "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "ba91378284c5dc3dc470f0601e4ff0202622a8d1fb97f27ab45c096d65c0bb48", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "038bb64a38ce": { "name": "gitlab.todos#1", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index 4d092d51f28..949144cabfe 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.task-list-linear", "family": "tasks.task-list-linear", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "216ddbf8cb71481d179563d8b033d7dc6cd71bface049e83073b977909776fc9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "104bf14d3af8": { "name": "error", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index 943dbd2171a..8b3955f1b9d 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-board-load", "family": "tasks.project-board-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "7840e811e81d3645f1c874b871158202a53432989f3b5c849df12550b082813b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "00ef2dbe5026": { "name": "github.project.resolveRef#1", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index 81ffe92128b..f43e31c9757 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-repo-slugs", "family": "tasks.project-repo-slugs", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", - "scenarioSha256": "a83d5768892764af2dd6866f03d51c09aef63d1c5d3e0645bd5e1c16454b201f", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3df58008cdfa": { "name": "githubRepoSlugCache", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json index 949d1dbdbbc..72963ec0353 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-comments-issue", "family": "tasks.project-row-comments-issue", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "6f1bfb05a9df6482200bbab4400fd07a09955d47fcc468e1d2feda1ca9875aa1", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1bf0937a02b9": { "name": "projectMutating", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json index 5c9d694994b..bf8fd791c85 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-comments-pr", "family": "tasks.project-row-comments-pr", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "e5ff558593fd32d66e6ba1722ebf54d50992c9c2b6db41ef2435b47972fbd0fd", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "25607177ea9a": { "name": "github.project.updatePullRequestBySlug#1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index 044028aef21..d8dfc11b2b3 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-detail", "family": "tasks.project-row-detail", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", - "scenarioSha256": "f572b37cfd15f41f283e5f96b772a1055670f80e68064ac81477d9b768fc971a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0216abb9c43b": { "name": "projectTitleDraft", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index 55ad5516365..7c0833d6161 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-fields", "family": "tasks.project-row-fields", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "af54a351f4b79fff4a11948fc47a9f5194733065682ff96d6b46b9ae292e327e", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "093815ac28fb": { "name": "github.project.clearItemField#1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json index 67a69ea0393..876d6e7a0e9 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-files-merge", "family": "tasks.project-row-files-merge", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", - "scenarioSha256": "fef96700723edcf7d0a55ee05928a0293b8a28217b161cd3823437f3bc33b1ec", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0dc3824edb1e": { "name": "projectRowDetailError", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json index 3904cef4311..45516d05450 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-metadata-load", "family": "tasks.project-row-metadata-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", - "scenarioSha256": "60ea389fe16734fb53db01101f1feb4496653a99faa09e4309b3790a50d558d7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "21ea4fab050f": { "name": "projectAssignableUsers", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json index da2395d92e5..c964d1bdaf2 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-review-checks", "family": "tasks.project-row-review-checks", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", - "scenarioSha256": "08caec8e0ab5e674aabbf034ca88fc4a000c4b645ad6397afe6dc18db1f7c098", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0af1de3f1c7b": { "name": "github.setPRFileViewed#1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index 70ceddb8092..f2fc0c1bc96 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.project-row-threads", "family": "tasks.project-row-threads", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", - "scenarioSha256": "815f55c0fb848fc9345a66bb7b71b1351f17e56129e005eb1776d6b47c831647", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "09c572b1820f": { "name": "github.addPRReviewCommentReply#1", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index 7d5ac795b30..8a08d7406b5 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.provider-load", "family": "tasks.provider-load", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", - "scenarioSha256": "21b6272878cba5f2b41c89378c178933ffc9406fe69b9c693fc5021a265ef2c9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "084da53ea1e3": { "name": "linear.status#1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json index 934a09b8de3..4c652da05c7 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.capability-probe", "family": "transport.capability-probe", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "c5d28c2973881ae6cc94c7d8f6eef544046461f15e236634489afd272b5f1e6b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0f656c54115f": { "name": "status.get#2", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json index f204e48c500..da4a77f4da8 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.capability-probe", "family": "transport.capability-probe", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "95ef0a8b60bf92ef5c12a73f923dc143989b34374fb319f812fbaa58c79aa6a6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "5b39cca91cc4": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index 6fe86b71a24..88d224f8b6d 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.capability-probe", "family": "transport.capability-probe", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "6e0c3a784992e383a05ccfdf34e44e6f74ebd55ff17c4de0b05b2dfb4197c681", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "8a814e9e8488": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json index 7d1c70b77d9..56ac28aab88 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.capability-probe", "family": "transport.capability-probe", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "2ba1e1d70c98e2fd0d2d2dce6f68c11a186756f05207747e156375fc613940d7", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "20578a5e9a12": { "name": "status.get#2", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json index 7e3d011ad97..18199c952fb 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.host-status-gates", "family": "transport.host-status-gates", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "f25f444aca6cf768c602bf879e6b30235d1c4c0632636cfd245bd6e27959756b", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36d81979cef2": { "appVersion": "1.4.200", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json index 49826dee6bd..9fe474da90c 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.host-status-gates", "family": "transport.host-status-gates", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "9c5095c24bdf5ab65d6387cc22b9984fee3aa7d5ce93d96bcb470944ac253f86", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "36d81979cef2": { "appVersion": "1.4.200", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json index a10131c2a39..352242e1b05 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.host-status-gates", "family": "transport.host-status-gates", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "a7871b5f1d37b0156970858d5a7fcab3105de7a8a6bfe827299a36f2b2ba5548", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "905661513589": { "name": "status.get#1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json index 460002781fd..b57df32c8bf 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.pairing-race", "family": "transport.pairing-race", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "4f0ddbea3c08ea3e90f6e707215a4831f06aed408065b45d0771028d256d6b12", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1329c4d27ca9": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json index 0413371cb08..34f78503e13 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.pairing-race", "family": "transport.pairing-race", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "488173fa313295f97aa88fb4bf1944fdb655e37cfd2444e9d15515bd6ad82d95", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "61a4715b43bf": { "name": "status.get#2", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json index f1bdfadf093..805a7cd473c 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.pairing-race", "family": "transport.pairing-race", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "dee9824e5ec32115fa7dfaaf223fc34c28d057a5526ac3dad42365543288a934", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2a6d50107927": { "name": "candidate-closed", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json index 51c4c340c41..d0dec1469b9 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "transport.pairing-race", "family": "transport.pairing-race", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", - "scenarioSha256": "a34ffec446f9bbc465bd3f7d0a43166c9bc221a6ece8714e0b5717169625cf43", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2a6d50107927": { "name": "candidate-closed", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 6cb44346bcd..685bf3dad64 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-capabilities", "family": "worktree.runtime-capabilities", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "9d79bcfd6957d11d5ce8c3296f1038a3cfab81eedad7f990b44071104dfd0f91", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "120979f40a68": { "capabilities": { diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 9c2c0ce90be..2c99de3ceb4 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-capabilities", "family": "worktree.runtime-capabilities", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "fb9c0e4b7c34f9bd1bd355b606c6ba75a7583cff3788000b7ed013612f5574fe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "32354557bece": { "capabilities": "unprobed" diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index 293d4054393..3681c1ff7c5 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-capabilities", "family": "worktree.runtime-capabilities", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "d73fed49e0a7e3e054d5c2fa75780f98bf78b6fa7e02f1ccb2fdc49465cf2fe5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "ab209a152529": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json index 06897da697d..bfb9e56cb0a 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.agent-launch-create", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "d193972745f28b4a8da9b19176ba9825b7b6eb78f28802c6f6eea99e155fdfe6", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3f946ad0279c": { "outcome": "uncreated" diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json index d0a290e02d5..d3b9f8a0919 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "a99bb80a5826af1df8d74114fcc5654aa42c9208747b512cea9bd5ca65b64ccb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json index 705f673283e..1761b9f228b 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "3cda09e4a4ad4092f5f9a48b7c9715a99a51eb3bc4bed00f7054537a9e21cea9", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json index a9ee18f5fd3..0ba2fe92c0d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "61097c13b262a4454510936fa9c9554a07865b610f18407f1b67bdd74476df08", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0de4a94be472": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index 3d401c51a14..3516f9f166e 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "0278216fee698c00118bb0e73a7fe755dc59c3b8e2b0459153edae64f155774c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json index 62b80b0b0f2..4ce29b95ab9 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "6cb658dadc9e146c4f36c6ce643451e72300b8cdda19cc684ffa9fb2b0822c0a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json index 7704fba5335..bf63ebfe2b9 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "16abe9e1a8d4cff17b3ea29d40277ae30a3d555a4a9efa08b2745c3a85b02740", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json index 7e8f49a396e..60c70e2429b 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.worktree-create-retry", "family": "worktree.create-retry", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "f469b2b7d61e7fc500fa97b5548f5a3732b0dbcdb405412a514a609f786dfbb5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "06b14e4985de": { "name": "worktree.create#1", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index ae16a873285..8d1e3e6d60f 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.composer-hosted-base", "family": "worktree.hosted-base", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "b3734c24f8a083d3efcdd995ea6a57e608d3d3ae3bbbd514db19dcf69448fa48", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "1788fb968913": { "name": "worktree.resolvePrBase#1", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json index f0814a999a1..0f8d53fd2cc 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.composer-hosted-base", "family": "worktree.hosted-base", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "451e74a430d2549976fa360a0e43e76a8d855b1470b8e313797019770ceca4cb", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "92df85b51d0c": { "name": "worktree.resolveMrBase#1", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index 9885cf5025e..fc98869224c 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "81be14c5ec31045004465de4234d1f682f5946d6f8dac7a42263e285b1f24591", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "088b54714e03": { "by-number": { diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json index ee51e1f7e20..c139e38f777 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "e9d85c576adf93063a8f56d49d28869c402cad38122732b46b8ec021d25db5e3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "35f3e39a1c50": { "cache": [ diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json index be1437b6558..fb6cef2cd6f 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.paste-lookup", "family": "tasks.paste-lookup", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "14509b4c1cc3beb00cc329b6bae46913f59b3938f76c0bd3bf3e374f34fb680d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "176039835400": { "cache": [ diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json index 1dc9de89672..b6ad246b6e4 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.setup-hook-trust", "family": "worktree.setup-hook-trust", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "0d2e3f48aadf45abbf6927b72ed5fef4caa8e3eb2efd1046339a3fbfab6f9f18", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "229c35d1a4ba": { "trust": "unapproved" diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json index 29f6508a102..32c6c80eb2c 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.setup-hook-trust", "family": "worktree.setup-hook-trust", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "f54f5c6dbbe7dbcf8e85e9bd36b27ca9bea7de65d5e35dabace49b3fc766a403", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "86d0d8f71257": { "name": "ui.set#1", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json index 8efd95cbd83..fb78d291682 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "5d869d55848aea469954eb945ddc2b74f366387f8fc2853b7ae01de779208c4a", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "005749a0bd75": { "name": "github.listWorkItems#1", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json index bc0034b9cf9..165d36f167f 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "4537bf83f7a030521eec549adf4490da5be183471d5a9f9e58b71815b29481ff", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "2978f9aceb1e": { "name": "repo.searchRefs#1", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json index 732ba4a5fc7..0e827d36891 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.smart-source-search", "family": "tasks.smart-source-search", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "9681485eec93644a01dd6dff0d07f9629361a86989a900176c5aaff3d5805db3", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "15d6e85f9f78": { "name": "linear.listIssues#1", diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json index ca77155f5df..5157520b878 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "settings.task-preferences", "family": "settings-best-effort", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", - "scenarioSha256": "31bfa49f888b0eb3f72873bf4a3af26129e78c23126e8fc8fe45b952caa60904", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "083a573d9009": { "name": "ui.set#2", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json index 4b8acbb2973..b72186c22d7 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-source", "family": "tasks.workspace-source", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "2cd1e8972f226572744dad7da82afffbdf0452a121c1cd8c3334d5c3fde5d57c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02e24a49522e": { "name": "repo.sparsePresets#1", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index d45a2618cd5..760d6e2fdd4 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-source", "family": "tasks.workspace-source", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "046dd3a125a3c9abcf5a0dd122818939b516adb91cbda2554b3409d4bb3a7980", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02e24a49522e": { "name": "repo.sparsePresets#1", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json index 9a3a7e5b6b9..6a3ce4642a2 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-sparse", "family": "tasks.workspace-sparse", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "865a659012dd882fd6073813585e2911a1d6252404fbf5a5e273f062b89fc91d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02daf4600c50": { "name": "repo.saveSparsePreset#1", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index 7dc69202c86..6a2548ac2df 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-sparse", "family": "tasks.workspace-sparse", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "124f664e339bfd83a1d892e1cd953a78fdf0dc4b20c4272356d24079c72a3e04", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "02daf4600c50": { "name": "repo.saveSparsePreset#1", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json index 58893ee5974..51a217e723f 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh", "family": "tasks.workspace-ssh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "dd25391fdd3dc864ae493f72d013e789884a21e9c71522edc79323bc2b6c7f76", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11405363c62d": { "name": "workspaceAgent", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index 1683f8218d2..8adacf14c7c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh", "family": "tasks.workspace-ssh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "495f51d9c2f7f3d71f53a53e88786b8d1f767a5bf66b8655c28222d2909a964c", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0ba2cee4b538": { "status": "fulfilled", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index 16c4f4555c9..c93a98b2e4c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh-local", "family": "tasks.workspace-ssh-local", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "af0623c2d106d2ed18ef9149d4990539f9ed82146ae091a872a3e1d792efeffe", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "11405363c62d": { "name": "workspaceAgent", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json index 7728d271c72..0c68932630e 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "tasks.workspace-ssh", "family": "tasks.workspace-ssh", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", - "scenarioSha256": "3aa23f15da8fe9972e47c767db454b41750ca353ab10797082fde4514ffe9da0", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0f1cf505ed63": { "status": "rejected", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index 8ebe6cbfabb..4935c51084f 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.catalog-snapshot", "family": "worktree.catalog-snapshot", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "e28e66567389598edbc720c89f5af73b5beca836cd0357af235d8ddc3d29fbfc", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "253b98015b8d": { "admitted": "unadmitted", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index a4a8ae811e5..ac097b5690c 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.catalog-snapshot", "family": "worktree.catalog-snapshot", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "d2947158840576cbd0f0604ed3d37b0f446c63c6d439b4d1b7def7fe8524523d", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "253b98015b8d": { "admitted": "unadmitted", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index 3b2f9113feb..438e69273f1 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.home-catalog", "family": "worktree.home-catalog", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "4749bb3b871275ba08f026f9b6bcfd383605f443e7bba70a7f89175b91db6fa5", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "0809e2178d5a": { "name": "info", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index 2bbd621adcb..98d6e137968 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -1,17 +1,8 @@ { + "goldenFormatVersion": 6, "operation": "worktree.retired-names", "family": "worktree.retired-names", "namedDeltas": [], - "runnerVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", - "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", - "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", - "scenarioSha256": "2faa07ee5f12b3ed584117359d3b7aeb9c78a04e8fc49e753372f8c3927a3740", - "platform": "darwin", - "scenarioVersion": 1, - "projectionVersion": 2, - "goldenFormatVersion": 5, "values": { "3a6762b141aa": { "name": "worktree.listRetiredNames#1", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 647e4dcad01..9d84eb2018d 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,11 +1,9 @@ { "schemaVersion": 1, - "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "scenarios": [ { "id": "b1", "operation": "workspace.file-inventory", - "version": 1, "family": "legacy-inventory", "sites": ["mobile/src/session/use-mobile-native-chat-file-search.ts"], "schedules": ["a-b-a", "stale-inflight-cleanup"], @@ -154,7 +152,6 @@ { "id": "b2", "operation": "project.update-metadata", - "version": 1, "family": "project-explicit-false", "sites": ["mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx"], "schedules": [], @@ -194,7 +191,6 @@ { "id": "b3", "operation": "linear.issue-detail", - "version": 1, "family": "linear-detail-barrier", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx"], "schedules": ["issue-first", "peer-pending"], @@ -242,7 +238,6 @@ { "id": "settings-new-tab-ssh", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["settings-first"], @@ -304,7 +299,6 @@ { "id": "settings-task-write", "operation": "settings.task-preferences", - "version": 1, "family": "settings-best-effort", "sites": ["mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx"], "schedules": [], @@ -317,7 +311,7 @@ "action": "write", "id": "write", "args": { - "preset": "assigned" + "preset": "my-issues" } }, { @@ -326,7 +320,7 @@ { "complete": "settings.update#1", "params": { - "defaultTaskViewPreset": "assigned" + "defaultTaskViewPreset": "my-issues" }, "reject": { "message": "write disconnected", @@ -341,7 +335,6 @@ { "id": "settings-bot-overrides-fulfilled", "operation": "settings.bot-overrides", - "version": 1, "family": "settings.bot-overrides", "sites": ["mobile/src/session/use-pr-bot-author-overrides.ts"], "schedules": ["settings-first"], @@ -379,7 +372,6 @@ { "id": "settings-bot-overrides-refused", "operation": "settings.bot-overrides", - "version": 1, "family": "settings.bot-overrides", "sites": ["mobile/src/session/use-pr-bot-author-overrides.ts"], "schedules": ["settings-first"], @@ -412,7 +404,6 @@ { "id": "settings-bot-overrides-transport-error", "operation": "settings.bot-overrides", - "version": 1, "family": "settings.bot-overrides", "sites": ["mobile/src/session/use-pr-bot-author-overrides.ts"], "schedules": ["settings-first"], @@ -442,7 +433,6 @@ { "id": "settings-workspace-context-fulfilled", "operation": "settings.workspace-context", - "version": 1, "family": "settings.workspace-context", "sites": ["mobile/src/components/use-new-workspace-runtime-context.ts"], "schedules": ["settings-first"], @@ -519,7 +509,6 @@ { "id": "settings-workspace-context-refused", "operation": "settings.workspace-context", - "version": 1, "family": "settings.workspace-context", "sites": ["mobile/src/components/use-new-workspace-runtime-context.ts"], "schedules": ["settings-first"], @@ -591,7 +580,6 @@ { "id": "settings-workspace-context-transport-error", "operation": "settings.workspace-context", - "version": 1, "family": "settings.workspace-context", "sites": ["mobile/src/components/use-new-workspace-runtime-context.ts"], "schedules": ["settings-first"], @@ -660,7 +648,6 @@ { "id": "settings-home-providers-fulfilled", "operation": "settings.home-providers", - "version": 1, "family": "settings.home-providers", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": ["settings-first"], @@ -724,7 +711,6 @@ { "id": "settings-home-providers-refused", "operation": "settings.home-providers", - "version": 1, "family": "settings.home-providers", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": ["settings-first"], @@ -783,7 +769,6 @@ { "id": "settings-home-providers-transport-error", "operation": "settings.home-providers", - "version": 1, "family": "settings.home-providers", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": ["settings-first"], @@ -839,7 +824,6 @@ { "id": "settings-resume-metadata-fulfilled", "operation": "settings.resume-metadata", - "version": 1, "family": "settings.resume-metadata", "sites": ["mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx"], "schedules": ["settings-first"], @@ -926,7 +910,6 @@ { "id": "settings-resume-metadata-refused", "operation": "settings.resume-metadata", - "version": 1, "family": "settings.resume-metadata", "sites": ["mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx"], "schedules": ["settings-first"], @@ -1008,7 +991,6 @@ { "id": "settings-resume-metadata-transport-error", "operation": "settings.resume-metadata", - "version": 1, "family": "settings.resume-metadata", "sites": ["mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx"], "schedules": ["settings-first"], @@ -1087,7 +1069,6 @@ { "id": "settings-repo-metadata-fulfilled", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["settings-first"], @@ -1181,7 +1162,6 @@ { "id": "settings-repo-metadata-icons", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["settings-first"], @@ -1293,7 +1273,6 @@ { "id": "settings-repo-metadata-refused", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["settings-first"], @@ -1382,7 +1361,6 @@ { "id": "settings-repo-metadata-transport-error", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["settings-first"], @@ -1468,7 +1446,6 @@ { "id": "settings-task-hydration-fulfilled", "operation": "settings.task-hydration", - "version": 1, "family": "settings.task-hydration", "sites": ["mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx"], "schedules": ["settings-first"], @@ -1556,7 +1533,6 @@ { "id": "settings-task-hydration-refused", "operation": "settings.task-hydration", - "version": 1, "family": "settings.task-hydration", "sites": ["mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx"], "schedules": ["settings-first"], @@ -1639,7 +1615,6 @@ { "id": "settings-task-hydration-transport-error", "operation": "settings.task-hydration", - "version": 1, "family": "settings.task-hydration", "sites": ["mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx"], "schedules": ["settings-first"], @@ -1719,7 +1694,6 @@ { "id": "settings-workspace-submit-fulfilled", "operation": "settings.workspace-submit", - "version": 1, "family": "settings.workspace-submit", "sites": ["mobile/src/components/use-new-workspace-create-submit.ts"], "schedules": ["settings-first"], @@ -1761,7 +1735,6 @@ { "id": "settings-workspace-submit-refused", "operation": "settings.workspace-submit", - "version": 1, "family": "settings.workspace-submit", "sites": ["mobile/src/components/use-new-workspace-create-submit.ts"], "schedules": ["settings-first"], @@ -1798,7 +1771,6 @@ { "id": "settings-workspace-submit-transport-error", "operation": "settings.workspace-submit", - "version": 1, "family": "settings.workspace-submit", "sites": ["mobile/src/components/use-new-workspace-create-submit.ts"], "schedules": ["settings-first"], @@ -1832,7 +1804,6 @@ { "id": "settings-task-workspace-fulfilled", "operation": "settings.task-workspace", - "version": 1, "family": "settings.task-workspace", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], "schedules": ["settings-first"], @@ -1874,7 +1845,6 @@ { "id": "settings-task-workspace-refused", "operation": "settings.task-workspace", - "version": 1, "family": "settings.task-workspace", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], "schedules": ["settings-first"], @@ -1911,7 +1881,6 @@ { "id": "settings-task-workspace-transport-error", "operation": "settings.task-workspace", - "version": 1, "family": "settings.task-workspace", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], "schedules": ["settings-first"], @@ -1945,7 +1914,6 @@ { "id": "settings-task-workspace-create-linear", "operation": "settings.task-workspace-create", - "version": 1, "family": "settings.task-workspace-create", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], "schedules": [], @@ -2019,7 +1987,6 @@ { "id": "settings-task-workspace-create-pr-start-point", "operation": "settings.task-workspace-create", - "version": 1, "family": "settings.task-workspace-create", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], "schedules": [], @@ -2112,7 +2079,6 @@ { "id": "settings-new-tab-refused", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["settings-first"], @@ -2172,7 +2138,6 @@ { "id": "settings-new-tab-transport-error", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["settings-first"], @@ -2229,7 +2194,6 @@ { "id": "settings-home-coalesced", "operation": "settings.home-providers", - "version": 1, "family": "settings.home-providers", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": ["stale-inflight-cleanup", "follow-up-cache-observation"], @@ -2351,7 +2315,6 @@ { "id": "settings-repo-cache-expiry", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["follow-up-cache-observation", "timer-expiry"], @@ -2472,7 +2435,6 @@ { "id": "inventory-lifecycle", "operation": "workspace.file-inventory", - "version": 1, "family": "legacy-inventory", "sites": ["mobile/src/session/use-mobile-native-chat-file-search.ts"], "schedules": ["reset", "unmount-remount", "blur-retained-route"], @@ -2537,7 +2499,6 @@ { "id": "inventory-repeat-query", "operation": "workspace.file-inventory", - "version": 1, "family": "legacy-inventory", "sites": ["mobile/src/session/use-mobile-native-chat-file-search.ts"], "schedules": ["normalized-repeat", "cache-cancels-debounce", "cache-stales-inflight"], @@ -2677,7 +2638,6 @@ { "id": "settings-repo-metadata-single-host", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["single-ssh-host"], @@ -2721,7 +2681,6 @@ { "id": "settings-bot-overrides-refresh-refused", "operation": "settings.bot-overrides", - "version": 1, "family": "settings.bot-overrides", "sites": ["mobile/src/session/use-pr-bot-author-overrides.ts"], "schedules": ["success-then-refused"], @@ -2782,7 +2741,6 @@ { "id": "settings-workspace-context-refuse-after-data", "operation": "settings.workspace-context", - "version": 1, "family": "settings.workspace-context", "sites": ["mobile/src/components/use-new-workspace-runtime-context.ts"], "schedules": ["success-then-refused"], @@ -2926,7 +2884,6 @@ { "id": "settings-home-providers-refuse-after-data", "operation": "settings.home-providers", - "version": 1, "family": "settings.home-providers", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": ["success-then-refused"], @@ -3042,7 +2999,6 @@ { "id": "settings-resume-metadata-refuse-after-data", "operation": "settings.resume-metadata", - "version": 1, "family": "settings.resume-metadata", "sites": ["mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx"], "schedules": ["success-then-refused"], @@ -3204,7 +3160,6 @@ { "id": "settings-repo-metadata-refuse-after-data", "operation": "settings.repo-metadata", - "version": 1, "family": "settings.repo-metadata", "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], "schedules": ["success-then-refused"], @@ -3376,7 +3331,6 @@ { "id": "settings-task-hydration-refuse-after-data", "operation": "settings.task-hydration", - "version": 1, "family": "settings.task-hydration", "sites": ["mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx"], "schedules": ["success-then-refused"], @@ -3540,7 +3494,6 @@ { "id": "probe-new-tab-both-refused", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["both-refused"], @@ -3603,7 +3556,6 @@ { "id": "probe-new-tab-refused-sibling-rejects", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["refused-sibling-rejects"], @@ -3663,7 +3615,6 @@ { "id": "probe-new-tab-rejects-sibling-refused", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["rejects-sibling-refused"], @@ -3723,7 +3674,6 @@ { "id": "probe-new-tab-null-sibling-refused", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings-agent-read", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": ["null-sibling-refused"], @@ -3783,7 +3733,6 @@ { "id": "sc-base-ref-default", "operation": "source-control.branch-base-ref", - "version": 1, "family": "git.base-ref-chain", "sites": ["mobile/src/source-control/mobile-branch-base-ref.ts"], "schedules": [], @@ -3849,7 +3798,6 @@ { "id": "sc-base-ref-worktree-hit", "operation": "source-control.branch-base-ref", - "version": 1, "family": "git.base-ref-chain", "sites": ["mobile/src/source-control/mobile-branch-base-ref.ts"], "schedules": [], @@ -3895,7 +3843,6 @@ { "id": "sc-base-ref-repo-fallback", "operation": "source-control.branch-base-ref", - "version": 1, "family": "git.base-ref-chain", "sites": ["mobile/src/source-control/mobile-branch-base-ref.ts"], "schedules": [], @@ -3942,7 +3889,6 @@ { "id": "sc-base-ref-unavailable", "operation": "source-control.branch-base-ref", - "version": 1, "family": "git.base-ref-chain", "sites": ["mobile/src/source-control/mobile-branch-base-ref.ts"], "schedules": [], @@ -3998,7 +3944,6 @@ { "id": "sc-history-loaded", "operation": "source-control.git-history", - "version": 1, "family": "git.history-read", "sites": ["mobile/src/source-control/mobile-git-history.ts"], "schedules": [], @@ -4046,7 +3991,6 @@ { "id": "sc-commit-message-generated", "operation": "source-control.commit-message", - "version": 1, "family": "git.commit-message-ai", "sites": ["mobile/src/source-control/mobile-commit-message-ai.ts"], "schedules": [], @@ -4079,7 +4023,6 @@ { "id": "sc-commit-message-canceled", "operation": "source-control.commit-message", - "version": 1, "family": "git.commit-message-ai", "sites": ["mobile/src/source-control/mobile-commit-message-ai.ts"], "schedules": [], @@ -4130,7 +4073,6 @@ { "id": "sc-commit-message-cancel-rejected", "operation": "source-control.commit-message", - "version": 1, "family": "git.commit-message-ai", "sites": ["mobile/src/source-control/mobile-commit-message-ai.ts"], "schedules": [], @@ -4157,7 +4099,6 @@ { "id": "sc-pr-link-set", "operation": "source-control.pr-link", - "version": 1, "family": "worktree.review-link", "sites": ["mobile/src/source-control/mobile-pr-link.ts"], "schedules": [], @@ -4190,7 +4131,6 @@ { "id": "sc-pr-link-hosted-review", "operation": "source-control.pr-link", - "version": 1, "family": "worktree.review-link", "sites": ["mobile/src/source-control/mobile-pr-link.ts"], "schedules": [], @@ -4238,7 +4178,6 @@ { "id": "sc-pr-link-read", "operation": "source-control.pr-link", - "version": 1, "family": "worktree.review-link", "sites": ["mobile/src/source-control/mobile-pr-link.ts"], "schedules": [], @@ -4286,7 +4225,6 @@ { "id": "sc-reveal-first-poll", "operation": "source-control.session-diff-reveal", - "version": 1, "family": "session.tab-reveal", "sites": ["mobile/src/source-control/reveal-mobile-source-control-session-diff.ts"], "schedules": [], @@ -4355,7 +4293,6 @@ { "id": "sc-reveal-timeout", "operation": "source-control.session-diff-reveal", - "version": 1, "family": "session.tab-reveal", "sites": ["mobile/src/source-control/reveal-mobile-source-control-session-diff.ts"], "schedules": [], @@ -4435,7 +4372,6 @@ { "id": "sc-review-status-normalized", "operation": "source-control.review-git-preparation", - "version": 1, "family": "git.review-preparation", "sites": ["mobile/src/source-control/mobile-hosted-review-git-preparation.ts"], "schedules": [], @@ -4485,7 +4421,6 @@ { "id": "sc-review-status-entries-not-array", "operation": "source-control.review-git-preparation", - "version": 1, "family": "git.review-preparation", "sites": ["mobile/src/source-control/mobile-hosted-review-git-preparation.ts"], "schedules": [], @@ -4515,7 +4450,6 @@ { "id": "sc-review-commit", "operation": "source-control.review-git-preparation", - "version": 1, "family": "git.review-preparation", "sites": ["mobile/src/source-control/mobile-hosted-review-git-preparation.ts"], "schedules": [], @@ -4546,7 +4480,6 @@ { "id": "sc-review-commit-inner-failure", "operation": "source-control.review-git-preparation", - "version": 1, "family": "git.review-preparation", "sites": ["mobile/src/source-control/mobile-hosted-review-git-preparation.ts"], "schedules": [], @@ -4577,7 +4510,6 @@ { "id": "sc-review-commit-refused-empty-message", "operation": "source-control.review-git-preparation", - "version": 1, "family": "git.review-preparation", "sites": ["mobile/src/source-control/mobile-hosted-review-git-preparation.ts"], "schedules": [], @@ -4608,7 +4540,6 @@ { "id": "sc-review-commit-rejected", "operation": "source-control.review-git-preparation", - "version": 1, "family": "git.review-preparation", "sites": ["mobile/src/source-control/mobile-hosted-review-git-preparation.ts"], "schedules": [], @@ -4636,7 +4567,6 @@ { "id": "sc-prerequisite-push", "operation": "source-control.remote-prerequisite", - "version": 1, "family": "git.remote-prerequisite", "sites": ["mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts"], "schedules": [], @@ -4671,7 +4601,6 @@ { "id": "sc-prerequisite-publish", "operation": "source-control.remote-prerequisite", - "version": 1, "family": "git.remote-prerequisite", "sites": ["mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts"], "schedules": [], @@ -4704,7 +4633,6 @@ { "id": "sc-prerequisite-force-with-lease", "operation": "source-control.remote-prerequisite", - "version": 1, "family": "git.remote-prerequisite", "sites": ["mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts"], "schedules": [], @@ -4738,7 +4666,6 @@ { "id": "sc-prerequisite-skipped", "operation": "source-control.remote-prerequisite", - "version": 1, "family": "git.remote-prerequisite", "sites": ["mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts"], "schedules": [], @@ -4758,7 +4685,6 @@ { "id": "sc-eligibility-fetched", "operation": "source-control.hosted-review-eligibility", - "version": 1, "family": "hostedReview.eligibility", "sites": ["mobile/src/source-control/mobile-hosted-review-service.ts"], "schedules": [], @@ -4802,7 +4728,6 @@ { "id": "sc-prefill-unavailable-on-refusal", "operation": "source-control.hosted-review-eligibility", - "version": 1, "family": "hostedReview.eligibility", "sites": ["mobile/src/source-control/mobile-hosted-review-service.ts"], "schedules": [], @@ -4837,7 +4762,6 @@ { "id": "sc-prefill-unavailable-on-rejection", "operation": "source-control.hosted-review-eligibility", - "version": 1, "family": "hostedReview.eligibility", "sites": ["mobile/src/source-control/mobile-hosted-review-service.ts"], "schedules": [], @@ -4869,7 +4793,6 @@ { "id": "sc-create-pushes-then-creates", "operation": "source-control.hosted-review-create", - "version": 1, "family": "hostedReview.create-chain", "sites": [ "mobile/src/source-control/mobile-hosted-review-service.ts", @@ -4948,7 +4871,6 @@ { "id": "sc-create-link-failure-is-non-fatal", "operation": "source-control.hosted-review-create", - "version": 1, "family": "hostedReview.create-chain", "sites": [ "mobile/src/source-control/mobile-hosted-review-service.ts", @@ -5005,7 +4927,6 @@ { "id": "sc-create-existing-review", "operation": "source-control.hosted-review-create", - "version": 1, "family": "hostedReview.create-chain", "sites": [ "mobile/src/source-control/mobile-hosted-review-service.ts", @@ -5064,7 +4985,6 @@ { "id": "sc-create-refused-empty-message", "operation": "source-control.hosted-review-create", - "version": 1, "family": "hostedReview.create-chain", "sites": [ "mobile/src/source-control/mobile-hosted-review-service.ts", @@ -5105,7 +5025,6 @@ { "id": "sc-create-rejected-empty-message", "operation": "source-control.hosted-review-create", - "version": 1, "family": "hostedReview.create-chain", "sites": [ "mobile/src/source-control/mobile-hosted-review-service.ts", @@ -5143,7 +5062,6 @@ { "id": "sc-create-intent-stage-commit-push-create", "operation": "source-control.create-intent", - "version": 1, "family": "hostedReview.create-intent", "sites": [ "mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts", @@ -5420,7 +5338,6 @@ { "id": "sc-create-intent-unlisted-provider", "operation": "source-control.create-intent", - "version": 1, "family": "hostedReview.create-intent", "sites": [ "mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts", @@ -5696,7 +5613,6 @@ { "id": "tw-create-retry-created", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5730,7 +5646,6 @@ { "id": "tw-create-retry-warning-kept", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5764,7 +5679,6 @@ { "id": "tw-create-retry-name-collision", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5816,7 +5730,6 @@ { "id": "tw-create-retry-unretryable-refusal", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5848,7 +5761,6 @@ { "id": "tw-create-retry-ambiguous-while-connected", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5877,7 +5789,6 @@ { "id": "tw-create-retry-ambiguous-after-drop", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5904,7 +5815,6 @@ { "id": "tw-create-retry-ambiguous-without-idempotency", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.create-retry", "sites": ["mobile/src/tasks/worktree-create-retry.ts"], "schedules": [], @@ -5935,7 +5845,6 @@ { "id": "tw-create-retry-agent-launched", "operation": "tasks.worktree-create-retry", - "version": 1, "family": "worktree.agent-launch-create", "sites": [ "mobile/src/tasks/worktree-create-retry.ts", @@ -5993,7 +5902,6 @@ { "id": "tw-capabilities-advertised", "operation": "tasks.worktree-capabilities", - "version": 1, "family": "worktree.runtime-capabilities", "sites": ["mobile/src/tasks/worktree-create-capability.ts"], "schedules": [], @@ -6026,7 +5934,6 @@ { "id": "tw-capabilities-legacy-idempotency", "operation": "tasks.worktree-capabilities", - "version": 1, "family": "worktree.runtime-capabilities", "sites": ["mobile/src/tasks/worktree-create-capability.ts"], "schedules": [], @@ -6055,7 +5962,6 @@ { "id": "tw-capabilities-cutover-retried", "operation": "tasks.worktree-capabilities", - "version": 1, "family": "worktree.runtime-capabilities", "sites": ["mobile/src/tasks/worktree-create-capability.ts"], "schedules": [], @@ -6098,7 +6004,6 @@ { "id": "tw-hosted-base-resolved", "operation": "tasks.composer-hosted-base", - "version": 1, "family": "worktree.hosted-base", "sites": ["mobile/src/tasks/composer-source-base-resolve.ts"], "schedules": [], @@ -6151,7 +6056,6 @@ { "id": "tw-hosted-base-soft-error", "operation": "tasks.composer-hosted-base", - "version": 1, "family": "worktree.hosted-base", "sites": ["mobile/src/tasks/composer-source-base-resolve.ts"], "schedules": [], @@ -6203,7 +6107,6 @@ { "id": "tw-setup-hook-trust-approved", "operation": "tasks.setup-hook-trust", - "version": 1, "family": "worktree.setup-hook-trust", "sites": ["mobile/src/tasks/setup-hook-trust.ts"], "schedules": [], @@ -6239,7 +6142,6 @@ { "id": "tw-setup-hook-trust-always", "operation": "tasks.setup-hook-trust", - "version": 1, "family": "worktree.setup-hook-trust", "sites": ["mobile/src/tasks/setup-hook-trust.ts"], "schedules": [], @@ -6278,7 +6180,6 @@ { "id": "tw-smart-search-all-providers", "operation": "tasks.smart-source-search", - "version": 1, "family": "tasks.smart-source-search", "sites": ["mobile/src/tasks/smart-source-search-requests.ts"], "schedules": [], @@ -6469,7 +6370,6 @@ { "id": "tw-smart-search-linear-listed", "operation": "tasks.smart-source-search", - "version": 1, "family": "tasks.smart-source-search", "sites": ["mobile/src/tasks/smart-source-search-requests.ts"], "schedules": [], @@ -6525,7 +6425,6 @@ { "id": "tw-smart-search-gitlab-provider-error", "operation": "tasks.smart-source-search", - "version": 1, "family": "tasks.smart-source-search", "sites": ["mobile/src/tasks/smart-source-search-requests.ts"], "schedules": [], @@ -6591,7 +6490,6 @@ { "id": "tw-paste-lookup-resolved", "operation": "tasks.paste-lookup", - "version": 1, "family": "tasks.paste-lookup", "sites": ["mobile/src/tasks/smart-source-paste-intent.ts"], "schedules": [], @@ -6711,7 +6609,6 @@ { "id": "tw-paste-lookup-slug-unsupported", "operation": "tasks.paste-lookup", - "version": 1, "family": "tasks.paste-lookup", "sites": ["mobile/src/tasks/smart-source-paste-intent.ts"], "schedules": [], @@ -6748,7 +6645,6 @@ { "id": "tw-paste-lookup-slug-refused", "operation": "tasks.paste-lookup", - "version": 1, "family": "tasks.paste-lookup", "sites": ["mobile/src/tasks/smart-source-paste-intent.ts"], "schedules": [], @@ -6788,7 +6684,6 @@ { "id": "tw-workspace-source-presets", "operation": "tasks.workspace-source", - "version": 1, "family": "tasks.workspace-source", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx"], "schedules": [], @@ -6844,7 +6739,6 @@ { "id": "tw-workspace-source-presets-refused", "operation": "tasks.workspace-source", - "version": 1, "family": "tasks.workspace-source", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx"], "schedules": [], @@ -6874,7 +6768,6 @@ { "id": "tw-workspace-sparse-saved", "operation": "tasks.workspace-sparse", - "version": 1, "family": "tasks.workspace-sparse", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx"], "schedules": [], @@ -6933,7 +6826,6 @@ { "id": "tw-workspace-sparse-missing-preset", "operation": "tasks.workspace-sparse", - "version": 1, "family": "tasks.workspace-sparse", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx"], "schedules": [], @@ -6979,7 +6871,6 @@ { "id": "tw-workspace-ssh-connected", "operation": "tasks.workspace-ssh", - "version": 1, "family": "tasks.workspace-ssh", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], "schedules": [], @@ -7059,7 +6950,6 @@ { "id": "tw-workspace-ssh-not-ready", "operation": "tasks.workspace-ssh", - "version": 1, "family": "tasks.workspace-ssh", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], "schedules": [], @@ -7131,7 +7021,6 @@ { "id": "tw-workspace-ssh-connect-refused", "operation": "tasks.workspace-ssh", - "version": 1, "family": "tasks.workspace-ssh", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], "schedules": [], @@ -7199,7 +7088,6 @@ { "id": "tw-workspace-ssh-local-agents", "operation": "tasks.workspace-ssh-local", - "version": 1, "family": "tasks.workspace-ssh-local", "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], "schedules": [], @@ -7226,7 +7114,6 @@ { "id": "tw-task-preferences-resume-write", "operation": "settings.task-preferences", - "version": 1, "family": "settings-best-effort", "sites": ["mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx"], "schedules": [], @@ -7289,7 +7176,6 @@ { "id": "files-ownership-ssh", "operation": "files.mutation-ownership", - "version": 1, "family": "files.mutation-ownership", "sites": ["mobile/src/files/mobile-file-mutation-ownership.ts"], "schedules": [], @@ -7353,7 +7239,6 @@ { "id": "files-ownership-local", "operation": "files.mutation-ownership", - "version": 1, "family": "files.mutation-ownership", "sites": ["mobile/src/files/mobile-file-mutation-ownership.ts"], "schedules": [], @@ -7396,7 +7281,6 @@ { "id": "files-preview-grant-refresh", "operation": "files.preview-load", - "version": 1, "family": "files.preview-load", "sites": [ "mobile/src/files/mobile-file-preview-request.ts", @@ -7471,7 +7355,6 @@ { "id": "files-preview-artifact-direct", "operation": "files.preview-load", - "version": 1, "family": "files.preview-load", "sites": [ "mobile/src/files/mobile-file-preview-request.ts", @@ -7507,7 +7390,6 @@ { "id": "files-preview-worktree", "operation": "files.preview-load", - "version": 1, "family": "files.preview-load", "sites": ["mobile/src/files/mobile-file-preview-request.ts"], "schedules": [], @@ -7539,7 +7421,6 @@ { "id": "files-preview-worktree-image", "operation": "files.preview-load", - "version": 1, "family": "files.preview-load", "sites": ["mobile/src/files/mobile-file-preview-request.ts"], "schedules": [], @@ -7575,7 +7456,6 @@ { "id": "files-preview-artifact-image", "operation": "files.preview-load", - "version": 1, "family": "files.preview-load", "sites": ["mobile/src/files/mobile-file-preview-request.ts"], "schedules": [], @@ -7612,7 +7492,6 @@ { "id": "files-save-verified", "operation": "files.preview-save", - "version": 1, "family": "files.preview-save", "sites": [ "mobile/src/files/mobile-file-preview-request.ts", @@ -7666,7 +7545,6 @@ { "id": "files-save-blind", "operation": "files.preview-save", - "version": 1, "family": "files.preview-save", "sites": [ "mobile/src/files/mobile-file-preview-request.ts", @@ -7701,7 +7579,6 @@ { "id": "files-tab-doc-shapes", "operation": "files.tab-doc", - "version": 1, "family": "files.tab-doc", "sites": ["mobile/src/files/mobile-file-tab-doc.ts"], "schedules": [], @@ -7772,7 +7649,6 @@ { "id": "components-target-ssh", "operation": "components.execution-target", - "version": 1, "family": "components.execution-target", "sites": ["mobile/src/components/use-new-workspace-execution-target.ts"], "schedules": [], @@ -7840,7 +7716,6 @@ { "id": "components-target-local", "operation": "components.execution-target-local", - "version": 1, "family": "components.execution-target-local", "sites": ["mobile/src/components/use-new-workspace-execution-target.ts"], "schedules": [], @@ -7870,7 +7745,6 @@ { "id": "components-setup-ask", "operation": "components.setup-script", - "version": 1, "family": "components.setup-script", "sites": ["mobile/src/components/use-new-workspace-setup-script.ts"], "schedules": [], @@ -7909,7 +7783,6 @@ { "id": "components-codex-capability", "operation": "components.codex-reset-capability", - "version": 1, "family": "components.codex-reset-capability", "sites": ["mobile/src/components/codex-reset-credit-capability.ts"], "schedules": [], @@ -7938,7 +7811,6 @@ { "id": "host-view-settings-sync", "operation": "host.view-settings", - "version": 1, "family": "host.view-settings", "sites": ["mobile/src/host-screen/use-host-view-settings.ts"], "schedules": [], @@ -7994,7 +7866,6 @@ { "id": "home-host-stats", "operation": "home.host-stats", - "version": 1, "family": "home.host-stats", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": [], @@ -8027,7 +7898,6 @@ { "id": "worktree-home-catalog", "operation": "worktree.home-catalog", - "version": 1, "family": "worktree.home-catalog", "sites": ["mobile/src/worktree/home-host-worktree-fetch.ts"], "schedules": [], @@ -8073,7 +7943,6 @@ { "id": "worktree-catalog-snapshot", "operation": "worktree.catalog-snapshot", - "version": 1, "family": "worktree.catalog-snapshot", "sites": ["mobile/src/worktree/worktree-catalog-snapshot-client.ts"], "schedules": [], @@ -8114,7 +7983,6 @@ { "id": "worktree-catalog-snapshot-unreadable", "operation": "worktree.catalog-snapshot", - "version": 1, "family": "worktree.catalog-snapshot", "sites": ["mobile/src/worktree/worktree-catalog-snapshot-client.ts"], "schedules": [], @@ -8145,7 +8013,6 @@ { "id": "worktree-retired-names", "operation": "worktree.retired-names", - "version": 1, "family": "worktree.retired-names", "sites": ["mobile/src/worktree/use-retired-worktree-names.ts"], "schedules": [], @@ -8182,7 +8049,6 @@ { "id": "host-worktree-actions-pin-open-delete", "operation": "host.worktree-actions", - "version": 1, "family": "host.worktree-actions", "sites": ["mobile/src/host-screen/use-host-worktree-actions.ts"], "schedules": [], @@ -8257,7 +8123,6 @@ { "id": "host-worktree-delete-refused", "operation": "host.worktree-actions", - "version": 1, "family": "host.worktree-actions", "sites": ["mobile/src/host-screen/use-host-worktree-actions.ts"], "schedules": [], @@ -8295,7 +8160,6 @@ { "id": "pr-read-surface", "operation": "session.pr-reads", - "version": 1, "family": "github.pr-read", "sites": ["mobile/src/session/github-pr-rpc.ts"], "schedules": [], @@ -8492,7 +8356,6 @@ { "id": "pr-read-fork-routing", "operation": "session.pr-reads", - "version": 1, "family": "github.pr-read", "sites": ["mobile/src/session/github-pr-rpc.ts"], "schedules": [], @@ -8598,7 +8461,6 @@ { "id": "pr-read-upstream-error", "operation": "session.pr-reads", - "version": 1, "family": "github.pr-read", "sites": ["mobile/src/session/github-pr-rpc.ts"], "schedules": [], @@ -8675,7 +8537,6 @@ { "id": "pr-sidebar-load", "operation": "session.pr-sidebar", - "version": 1, "family": "session.pr-sidebar", "sites": ["mobile/src/session/mobile-pr-sidebar-state.ts"], "schedules": [], @@ -8775,7 +8636,6 @@ { "id": "pr-sidebar-checks-refused", "operation": "session.pr-sidebar", - "version": 1, "family": "session.pr-sidebar", "sites": ["mobile/src/session/mobile-pr-sidebar-state.ts"], "schedules": [], @@ -8870,7 +8730,6 @@ { "id": "pr-mutation-status", "operation": "session.pr-mutations", - "version": 1, "family": "github.pr-mutation", "sites": ["mobile/src/session/github-pr-mutations.ts"], "schedules": [], @@ -9012,7 +8871,6 @@ { "id": "pr-mutation-in-band-failure", "operation": "session.pr-mutations", - "version": 1, "family": "github.pr-mutation", "sites": ["mobile/src/session/github-pr-mutations.ts"], "schedules": [], @@ -9114,7 +8972,6 @@ { "id": "pr-comment-mutation", "operation": "session.pr-mutations", - "version": 1, "family": "github.pr-comment-mutation", "sites": ["mobile/src/session/github-pr-mutations.ts"], "schedules": [], @@ -9242,7 +9099,6 @@ { "id": "pr-comment-resolve-unconfirmed", "operation": "session.pr-mutations", - "version": 1, "family": "github.pr-comment-mutation", "sites": ["mobile/src/session/github-pr-mutations.ts"], "schedules": [], @@ -9289,7 +9145,6 @@ { "id": "pr-title-mutation", "operation": "session.pr-mutations", - "version": 1, "family": "github.pr-title-mutation", "sites": ["mobile/src/session/github-pr-mutations.ts"], "schedules": [], @@ -9318,7 +9173,6 @@ { "id": "pr-title-unconfirmed", "operation": "session.pr-mutations", - "version": 1, "family": "github.pr-title-mutation", "sites": ["mobile/src/session/github-pr-mutations.ts"], "schedules": [], @@ -9369,7 +9223,6 @@ { "id": "pr-triage-launch", "operation": "session.pr-triage-launch", - "version": 1, "family": "session.pr-triage", "sites": ["mobile/src/session/pr-ai-triage-launch.ts"], "schedules": [], @@ -9466,7 +9319,6 @@ { "id": "pr-triage-prompt-not-delivered", "operation": "session.pr-triage-launch", - "version": 1, "family": "session.pr-triage", "sites": ["mobile/src/session/pr-ai-triage-launch.ts"], "schedules": [], @@ -9560,7 +9412,6 @@ { "id": "pr-triage-launch-unconfirmed", "operation": "session.pr-triage-launch", - "version": 1, "family": "session.pr-triage", "sites": ["mobile/src/session/pr-ai-triage-launch.ts"], "schedules": [], @@ -9641,7 +9492,6 @@ { "id": "pr-triage-update-required", "operation": "session.pr-triage-launch", - "version": 1, "family": "session.pr-triage", "sites": ["mobile/src/session/pr-ai-triage-launch.ts"], "schedules": [], @@ -9661,7 +9511,6 @@ { "id": "pr-branch-identity", "operation": "session.pr-branch-context", - "version": 1, "family": "session.pr-branch-context", "sites": ["mobile/src/session/use-mobile-pr-branch-context.ts"], "schedules": [], @@ -9743,10 +9592,10 @@ "result": { "summary": { "baseRef": "origin/main", - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "changedFiles": 1, "status": "ready" }, @@ -9769,7 +9618,6 @@ { "id": "pr-branch-repo-context", "operation": "session.pr-branch-context", - "version": 1, "family": "session.pr-branch-context", "sites": ["mobile/src/session/use-mobile-pr-branch-context.ts"], "schedules": [], @@ -9800,7 +9648,6 @@ { "id": "diff-review-snapshot", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -9913,10 +9760,10 @@ "result": { "summary": { "baseRef": "origin/main", - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "changedFiles": 1, "status": "ready" }, @@ -9939,7 +9786,6 @@ { "id": "diff-review-status-unavailable", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -9969,7 +9815,6 @@ { "id": "diff-review-worktree-file-diff", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -10052,7 +9897,6 @@ { "id": "diff-review-refused-file-diff", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -10138,7 +9982,6 @@ { "id": "diff-review-branch-file-diff", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -10157,9 +10000,9 @@ "filePath": "src/app.ts", "compare": { "baseRef": "origin/main", - "baseOid": "base-oid", - "headOid": "head-oid", - "mergeBase": "merge-base" + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" } }, "reply": { @@ -10188,7 +10031,6 @@ { "id": "diff-review-branch-compare", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -10304,7 +10146,6 @@ { "id": "diff-review-notes-refused-before-compare", "operation": "session.diff-review-load", - "version": 1, "family": "session.diff-review", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -10413,10 +10254,10 @@ "result": { "summary": { "baseRef": "origin/main", - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "changedFiles": 1, "status": "ready" }, @@ -10439,7 +10280,6 @@ { "id": "transport-host-status-gates-ready", "operation": "transport.host-status-gates", - "version": 1, "family": "transport.host-status-gates", "sites": ["mobile/src/transport/host-status-gates.ts"], "schedules": [], @@ -10472,7 +10312,6 @@ { "id": "transport-host-status-gates-refused-degrades", "operation": "transport.host-status-gates", - "version": 1, "family": "transport.host-status-gates", "sites": ["mobile/src/transport/host-status-gates.ts"], "schedules": [], @@ -10502,7 +10341,6 @@ { "id": "transport-host-status-gates-drop-keeps-capabilities", "operation": "transport.host-status-gates", - "version": 1, "family": "transport.host-status-gates", "sites": ["mobile/src/transport/host-status-gates.ts"], "schedules": [], @@ -10545,7 +10383,6 @@ { "id": "transport-capability-probe-publishes", "operation": "transport.capability-probe", - "version": 1, "family": "transport.capability-probe", "sites": ["mobile/src/transport/runtime-capability-probe.ts"], "schedules": [], @@ -10574,7 +10411,6 @@ { "id": "transport-capability-probe-refused-backs-off", "operation": "transport.capability-probe", - "version": 1, "family": "transport.capability-probe", "sites": ["mobile/src/transport/runtime-capability-probe.ts"], "schedules": [], @@ -10622,7 +10458,6 @@ { "id": "transport-capability-probe-non-string-capabilities-drop", "operation": "transport.capability-probe", - "version": 1, "family": "transport.capability-probe", "sites": ["mobile/src/transport/runtime-capability-probe.ts"], "schedules": [], @@ -10658,7 +10493,6 @@ { "id": "transport-pairing-race-relay-completes-first", "operation": "transport.pairing-race", - "version": 1, "family": "transport.pairing-race", "sites": ["mobile/src/transport/pairing-candidate-race.ts"], "schedules": [], @@ -10713,7 +10547,6 @@ { "id": "transport-pairing-race-direct-completes-first", "operation": "transport.pairing-race", - "version": 1, "family": "transport.pairing-race", "sites": ["mobile/src/transport/pairing-candidate-race.ts"], "schedules": [], @@ -10768,7 +10601,6 @@ { "id": "transport-pairing-race-relay-wins-when-direct-refused", "operation": "transport.pairing-race", - "version": 1, "family": "transport.pairing-race", "sites": ["mobile/src/transport/pairing-candidate-race.ts"], "schedules": [], @@ -10824,7 +10656,6 @@ { "id": "transport-pairing-race-both-refused", "operation": "transport.pairing-race", - "version": 1, "family": "transport.pairing-race", "sites": ["mobile/src/transport/pairing-candidate-race.ts"], "schedules": [], @@ -10881,7 +10712,6 @@ { "id": "relay-rotation-installs-and-commits", "operation": "relay.credential-rotation", - "version": 1, "family": "relay.credential-rotation", "sites": ["mobile/src/transport/mobile-relay-credential-rotation.ts"], "schedules": [], @@ -10973,7 +10803,6 @@ { "id": "relay-rotation-resumes-committed-pending", "operation": "relay.credential-rotation", - "version": 1, "family": "relay.credential-rotation", "sites": ["mobile/src/transport/mobile-relay-credential-rotation.ts"], "schedules": [], @@ -11026,7 +10855,6 @@ { "id": "relay-direct-upgrade-commits", "operation": "relay.direct-upgrade", - "version": 1, "family": "relay.direct-upgrade", "sites": ["mobile/src/transport/mobile-relay-direct-upgrade.ts"], "schedules": [], @@ -11120,7 +10948,6 @@ { "id": "relay-direct-upgrade-unsupported-host-declines", "operation": "relay.direct-upgrade", - "version": 1, "family": "relay.direct-upgrade", "sites": ["mobile/src/transport/mobile-relay-direct-upgrade.ts"], "schedules": [], @@ -11153,7 +10980,6 @@ { "id": "relay-pairing-recovery-resume-committed", "operation": "relay.pairing-recovery", - "version": 1, "family": "relay.pairing-recovery", "sites": ["mobile/src/transport/mobile-relay-pairing-recovery.ts"], "schedules": [], @@ -11203,7 +11029,6 @@ { "id": "relay-pairing-recovery-invite-authorizes", "operation": "relay.pairing-recovery", - "version": 1, "family": "relay.pairing-recovery", "sites": ["mobile/src/transport/mobile-relay-pairing-recovery.ts"], "schedules": [], @@ -11320,7 +11145,6 @@ { "id": "pairing-pre-profile-direct-wins-and-provisions", "operation": "pairing.pre-profile", - "version": 1, "family": "pairing.pre-profile", "sites": [ "mobile/src/transport/pre-profile-pairing-coordinator.ts", @@ -11427,7 +11251,6 @@ { "id": "pairing-pre-profile-provision-unsupported-saves-direct-host", "operation": "pairing.pre-profile", - "version": 1, "family": "pairing.pre-profile", "sites": [ "mobile/src/transport/pre-profile-pairing-coordinator.ts", @@ -11499,7 +11322,6 @@ { "id": "pairing-pre-profile-times-out", "operation": "pairing.pre-profile", - "version": 1, "family": "pairing.pre-profile", "sites": [ "mobile/src/transport/pre-profile-pairing-coordinator.ts", @@ -11528,7 +11350,6 @@ { "id": "transport-capability-probe-cutover-reasks-fast", "operation": "transport.capability-probe", - "version": 1, "family": "transport.capability-probe", "sites": ["mobile/src/transport/runtime-capability-probe.ts"], "schedules": [], @@ -11574,7 +11395,6 @@ { "id": "tk-item-detail-github", "operation": "tasks.item-detail-github", - "version": 1, "family": "tasks.item-detail-github", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx"], "schedules": [], @@ -11618,7 +11438,6 @@ { "id": "tk-item-detail-github-reactions", "operation": "tasks.item-detail-github", - "version": 1, "family": "tasks.item-detail-github", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx"], "schedules": [], @@ -11681,7 +11500,6 @@ { "id": "tk-item-detail-gitlab", "operation": "tasks.item-detail-gitlab", - "version": 1, "family": "tasks.item-detail-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx"], "schedules": [], @@ -11694,7 +11512,7 @@ "complete": "gitlab.workItemDetails#1", "params": { "iid": 4, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1", "type": "issue" }, @@ -11725,7 +11543,6 @@ { "id": "tk-item-detail-gitlab-reactions", "operation": "tasks.item-detail-gitlab", - "version": 1, "family": "tasks.item-detail-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx"], "schedules": [], @@ -11738,7 +11555,7 @@ "complete": "gitlab.workItemDetails#1", "params": { "iid": 4, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1", "type": "issue" }, @@ -11784,7 +11601,6 @@ { "id": "tk-item-detail-linear", "operation": "tasks.item-detail-linear", - "version": 1, "family": "tasks.item-detail-linear", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx"], "schedules": [], @@ -11853,7 +11669,6 @@ { "id": "tk-item-detail-metadata", "operation": "tasks.item-detail-metadata", - "version": 1, "family": "tasks.item-detail-metadata", "sites": ["mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx"], "schedules": [], @@ -11896,7 +11711,6 @@ { "id": "tk-linear-team-context", "operation": "tasks.linear-team-context", - "version": 1, "family": "tasks.linear-team-context", "sites": ["mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx"], "schedules": [], @@ -11959,7 +11773,6 @@ { "id": "tk-provider-load", "operation": "tasks.provider-load", - "version": 1, "family": "tasks.provider-load", "sites": ["mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx"], "schedules": [], @@ -12092,7 +11905,6 @@ { "id": "tk-list-gitlab-todos", "operation": "tasks.task-list-gitlab-todos", - "version": 1, "family": "tasks.task-list-gitlab-todos", "sites": ["mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx"], "schedules": [], @@ -12137,7 +11949,6 @@ { "id": "tk-list-gitlab-items", "operation": "tasks.task-list-gitlab-items", - "version": 1, "family": "tasks.task-list-gitlab-items", "sites": ["mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx"], "schedules": [], @@ -12188,7 +11999,6 @@ { "id": "tk-list-linear", "operation": "tasks.task-list-linear", - "version": 1, "family": "tasks.task-list-linear", "sites": ["mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx"], "schedules": [], @@ -12296,7 +12106,6 @@ { "id": "tk-linear-connect", "operation": "tasks.linear-connect", - "version": 1, "family": "tasks.linear-connect", "sites": ["mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx"], "schedules": [], @@ -12329,7 +12138,6 @@ { "id": "tk-create-github", "operation": "tasks.task-create-github", - "version": 1, "family": "tasks.task-create-github", "sites": ["mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx"], "schedules": [], @@ -12388,7 +12196,6 @@ { "id": "tk-create-gitlab", "operation": "tasks.task-create-gitlab", - "version": 1, "family": "tasks.task-create-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx"], "schedules": [], @@ -12425,7 +12232,6 @@ { "id": "tk-create-linear", "operation": "tasks.task-create-linear", - "version": 1, "family": "tasks.task-create-linear", "sites": ["mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx"], "schedules": [], @@ -12465,7 +12271,6 @@ { "id": "tk-item-comment-github", "operation": "tasks.item-comment-github", - "version": 1, "family": "tasks.item-comment-github", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx"], "schedules": [], @@ -12507,7 +12312,6 @@ { "id": "tk-item-review-github", "operation": "tasks.item-review-github", - "version": 1, "family": "tasks.item-review-github", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx"], "schedules": [], @@ -12569,7 +12373,6 @@ { "id": "tk-item-comment-gitlab", "operation": "tasks.item-comment-gitlab", - "version": 1, "family": "tasks.item-comment-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx"], "schedules": [], @@ -12587,7 +12390,7 @@ "params": { "body": "a comment", "number": 4, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1" }, "reply": { @@ -12611,7 +12414,6 @@ { "id": "tk-item-comment-gitlab-mr", "operation": "tasks.item-comment-gitlab-mr", - "version": 1, "family": "tasks.item-comment-gitlab-mr", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx"], "schedules": [], @@ -12629,7 +12431,7 @@ "params": { "body": "a comment", "iid": 7, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1" }, "reply": { @@ -12653,7 +12455,6 @@ { "id": "tk-item-checks-files", "operation": "tasks.item-checks-files-github", - "version": 1, "family": "tasks.item-checks-files", "sites": ["mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx"], "schedules": [], @@ -12790,7 +12591,6 @@ { "id": "tk-item-reply-merge", "operation": "tasks.item-reply-merge-github", - "version": 1, "family": "tasks.item-reply-merge", "sites": ["mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx"], "schedules": [], @@ -12910,7 +12710,6 @@ { "id": "tk-item-merge-gitlab", "operation": "tasks.item-merge-gitlab", - "version": 1, "family": "tasks.item-merge-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx"], "schedules": [], @@ -12928,7 +12727,7 @@ "params": { "iid": 7, "method": "squash", - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1" }, "reply": { @@ -12946,7 +12745,6 @@ { "id": "tk-item-status-gitlab", "operation": "tasks.item-status-gitlab", - "version": 1, "family": "tasks.item-status-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx"], "schedules": [], @@ -12963,7 +12761,7 @@ "complete": "gitlab.updateIssue#1", "params": { "number": 4, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1", "updates": { "state": "closed" @@ -13009,7 +12807,6 @@ { "id": "tk-item-status-gitlab-mr", "operation": "tasks.item-status-gitlab-mr", - "version": 1, "family": "tasks.item-status-gitlab-mr", "sites": ["mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx"], "schedules": [], @@ -13026,7 +12823,7 @@ "complete": "gitlab.updateMRState#1", "params": { "iid": 7, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1", "state": "closed" }, @@ -13045,7 +12842,6 @@ { "id": "tk-item-metadata-github", "operation": "tasks.item-metadata-github", - "version": 1, "family": "tasks.item-metadata-github", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx"], "schedules": [], @@ -13083,7 +12879,6 @@ { "id": "tk-item-metadata-gitlab", "operation": "tasks.item-metadata-gitlab", - "version": 1, "family": "tasks.item-metadata-gitlab", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx"], "schedules": [], @@ -13100,7 +12895,7 @@ "complete": "gitlab.updateIssue#1", "params": { "number": 4, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -13122,7 +12917,6 @@ { "id": "tk-item-metadata-gitlab-mr", "operation": "tasks.item-metadata-gitlab-mr", - "version": 1, "family": "tasks.item-metadata-gitlab-mr", "sites": ["mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx"], "schedules": [], @@ -13139,7 +12933,7 @@ "complete": "gitlab.updateMR#1", "params": { "iid": 7, - "projectRef": "group/project", + "projectRef": { "host": "gitlab.com", "path": "group/project" }, "repo": "id:repo-1", "updates": { "addLabels": ["triage"], @@ -13167,7 +12961,6 @@ { "id": "tk-linear-item", "operation": "tasks.linear-item-actions", - "version": 1, "family": "tasks.linear-item", "sites": ["mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx"], "schedules": [], @@ -13269,7 +13062,6 @@ { "id": "tk-project-repo-slugs", "operation": "tasks.project-repo-slugs", - "version": 1, "family": "tasks.project-repo-slugs", "sites": ["mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx"], "schedules": [], @@ -13300,7 +13092,6 @@ { "id": "tk-project-board-load", "operation": "tasks.project-board-load", - "version": 1, "family": "tasks.project-board-load", "sites": ["mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx"], "schedules": [], @@ -13461,7 +13252,6 @@ { "id": "tk-project-row-detail", "operation": "tasks.project-row-detail", - "version": 1, "family": "tasks.project-row-detail", "sites": ["mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx"], "schedules": [], @@ -13507,7 +13297,6 @@ { "id": "tk-project-row-metadata-load", "operation": "tasks.project-row-metadata-load", - "version": 1, "family": "tasks.project-row-metadata-load", "sites": ["mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx"], "schedules": [], @@ -13580,7 +13369,6 @@ { "id": "tk-project-row-fields", "operation": "tasks.project-row-fields", - "version": 1, "family": "tasks.project-row-fields", "sites": ["mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx"], "schedules": [], @@ -13665,7 +13453,6 @@ { "id": "tk-project-row-review-checks", "operation": "tasks.project-row-review-checks", - "version": 1, "family": "tasks.project-row-review-checks", "sites": ["mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx"], "schedules": [], @@ -13789,7 +13576,6 @@ { "id": "tk-project-row-threads", "operation": "tasks.project-row-threads", - "version": 1, "family": "tasks.project-row-threads", "sites": ["mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx"], "schedules": [], @@ -13921,7 +13707,6 @@ { "id": "tk-project-row-comments-issue", "operation": "tasks.project-row-comments-issue", - "version": 1, "family": "tasks.project-row-comments-issue", "sites": ["mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx"], "schedules": [], @@ -14012,7 +13797,6 @@ { "id": "tk-project-row-comments-pr", "operation": "tasks.project-row-comments-pr", - "version": 1, "family": "tasks.project-row-comments-pr", "sites": ["mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx"], "schedules": [], @@ -14051,7 +13835,6 @@ { "id": "tk-project-row-files-merge", "operation": "tasks.project-row-files-merge", - "version": 1, "family": "tasks.project-row-files-merge", "sites": ["mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx"], "schedules": [], @@ -14209,7 +13992,6 @@ { "id": "speech-setup-sheet-fulfilled", "operation": "speech.setup-sheet", - "version": 1, "family": "speech.setup-sheet", "sites": ["mobile/src/dictation/mobile-dictation-setup.ts"], "schedules": [], @@ -14303,7 +14085,6 @@ { "id": "speech-setup-sheet-model-vocabulary", "operation": "speech.setup-sheet", - "version": 1, "family": "speech.setup-sheet", "sites": ["mobile/src/dictation/mobile-dictation-setup.ts"], "schedules": [], @@ -14352,7 +14133,6 @@ { "id": "speech-setup-sheet-legacy-desktop", "operation": "speech.setup-sheet", - "version": 1, "family": "speech.setup-sheet", "sites": ["mobile/src/dictation/mobile-dictation-setup.ts"], "schedules": [], @@ -14380,7 +14160,6 @@ { "id": "speech-setup-sheet-denied-to-mobile", "operation": "speech.setup-sheet", - "version": 1, "family": "speech.setup-sheet", "sites": ["mobile/src/dictation/mobile-dictation-setup.ts"], "schedules": [], @@ -14408,7 +14187,6 @@ { "id": "speech-desktop-start-superseded", "operation": "speech.desktop-start", - "version": 1, "family": "speech.dictation-start", "sites": ["mobile/src/hooks/mobile-dictation-desktop-start.ts"], "schedules": [], @@ -14453,7 +14231,6 @@ { "id": "speech-desktop-start-fulfilled", "operation": "speech.desktop-start", - "version": 1, "family": "speech.dictation-start", "sites": ["mobile/src/hooks/mobile-dictation-desktop-start.ts"], "schedules": [], @@ -14482,7 +14259,6 @@ { "id": "speech-desktop-start-recording-failed", "operation": "speech.desktop-start", - "version": 1, "family": "speech.dictation-start", "sites": ["mobile/src/hooks/mobile-dictation-desktop-start.ts"], "schedules": [], @@ -14526,7 +14302,6 @@ { "id": "speech-audio-chunk-acknowledged", "operation": "speech.audio-chunk", - "version": 1, "family": "speech.dictation-chunk", "sites": ["mobile/src/hooks/mobile-dictation-audio-chunk.ts"], "schedules": [], @@ -14557,7 +14332,6 @@ { "id": "speech-dictation-session-transcript", "operation": "speech.dictation-session", - "version": 1, "family": "speech.dictation-session", "sites": ["mobile/src/hooks/use-mobile-dictation.ts"], "schedules": [], @@ -14606,7 +14380,6 @@ { "id": "speech-dictation-session-cancelled", "operation": "speech.dictation-session", - "version": 1, "family": "speech.dictation-session", "sites": ["mobile/src/hooks/use-mobile-dictation.ts"], "schedules": [], @@ -14655,7 +14428,6 @@ { "id": "aivault-history-scan-fulfilled", "operation": "aiVault.history-scan", - "version": 1, "family": "aiVault.history", "sites": ["mobile/src/agent-history/use-mobile-agent-history-state.ts"], "schedules": [], @@ -14705,7 +14477,6 @@ { "id": "aivault-history-scan-unsupported", "operation": "aiVault.history-scan", - "version": 1, "family": "aiVault.history", "sites": ["mobile/src/agent-history/use-mobile-agent-history-state.ts"], "schedules": [], @@ -14734,7 +14505,6 @@ { "id": "aivault-history-scan-worktrees-late", "operation": "aiVault.history-scan", - "version": 1, "family": "aiVault.history", "sites": ["mobile/src/agent-history/use-mobile-agent-history-state.ts"], "schedules": [], @@ -14806,7 +14576,6 @@ { "id": "terminal-query-reply-accepted", "operation": "terminal.query-reply", - "version": 1, "family": "terminal.query-reply", "sites": ["mobile/src/terminal/mobile-terminal-query-reply.ts"], "schedules": [], @@ -14844,7 +14613,6 @@ { "id": "terminal-query-reply-unsubscribed", "operation": "terminal.query-reply", - "version": 1, "family": "terminal.query-reply", "sites": ["mobile/src/terminal/mobile-terminal-query-reply.ts"], "schedules": [], @@ -14864,7 +14632,6 @@ { "id": "terminal-raw-input-reported", "operation": "terminal.accessory-raw-send", - "version": 1, "family": "terminal.raw-input", "sites": [ "mobile/src/terminal/terminal-live-accessory-raw-send.ts", @@ -14916,7 +14683,6 @@ { "id": "terminal-raw-input-refused", "operation": "terminal.accessory-raw-send", - "version": 1, "family": "terminal.raw-input", "sites": [ "mobile/src/terminal/terminal-live-accessory-raw-send.ts", @@ -14956,7 +14722,6 @@ { "id": "terminal-takeover-report-retried", "operation": "terminal.takeover-report", - "version": 1, "family": "terminal.takeover-report", "sites": ["mobile/src/terminal/worker-terminal-takeover-report.ts"], "schedules": [], @@ -15001,7 +14766,6 @@ { "id": "terminal-takeover-report-accepted", "operation": "terminal.takeover-report", - "version": 1, "family": "terminal.takeover-report", "sites": ["mobile/src/terminal/worker-terminal-takeover-report.ts"], "schedules": [], @@ -15030,7 +14794,6 @@ { "id": "terminal-viewport-refit-applied", "operation": "terminal.viewport-refit", - "version": 1, "family": "terminal.viewport-refit", "sites": ["mobile/src/terminal/terminal-viewport-refit.ts"], "schedules": [], @@ -15075,7 +14838,6 @@ { "id": "terminal-viewport-refit-legacy-desktop", "operation": "terminal.viewport-refit", - "version": 1, "family": "terminal.viewport-refit", "sites": ["mobile/src/terminal/terminal-viewport-refit.ts"], "schedules": [], @@ -15120,7 +14882,6 @@ { "id": "notifications-push-registered", "operation": "notifications.push-registration", - "version": 1, "family": "notifications.push-registration", "sites": ["mobile/src/notifications/push-registration.ts"], "schedules": [], @@ -15136,6 +14897,7 @@ "onlyWhenDesktopAway": true, "sound": true }, + "apnsEnvironment": "production", "platform": "ios", "token": "apns-token-1" }, @@ -15169,7 +14931,6 @@ { "id": "notifications-push-gateway-rejected", "operation": "notifications.push-registration", - "version": 1, "family": "notifications.push-registration", "sites": ["mobile/src/notifications/push-registration.ts"], "schedules": [], @@ -15185,6 +14946,7 @@ "onlyWhenDesktopAway": true, "sound": true }, + "apnsEnvironment": "production", "platform": "ios", "token": "apns-token-1" }, @@ -15204,7 +14966,6 @@ { "id": "browser-pointer-click-fallback", "operation": "browser.page-commands", - "version": 1, "family": "browser.pointer-click", "sites": [ "mobile/src/browser/use-mobile-browser-request.ts", @@ -15290,7 +15051,6 @@ { "id": "browser-pointer-click-accepted", "operation": "browser.page-commands", - "version": 1, "family": "browser.pointer-click", "sites": [ "mobile/src/browser/use-mobile-browser-request.ts", @@ -15332,7 +15092,6 @@ { "id": "browser-wheel-scrolled", "operation": "browser.page-commands", - "version": 1, "family": "browser.wheel", "sites": [ "mobile/src/browser/use-mobile-browser-request.ts", @@ -15386,7 +15145,6 @@ { "id": "browser-keyboard-input", "operation": "browser.page-commands", - "version": 1, "family": "browser.keyboard", "sites": [ "mobile/src/browser/use-mobile-browser-request.ts", @@ -15442,7 +15200,6 @@ { "id": "browser-dialog-accepted", "operation": "browser.page-commands", - "version": 1, "family": "browser.dialog", "sites": [ "mobile/src/browser/use-mobile-browser-request.ts", @@ -15479,7 +15236,6 @@ { "id": "browser-dialog-dismissed", "operation": "browser.page-commands", - "version": 1, "family": "browser.dialog", "sites": [ "mobile/src/browser/use-mobile-browser-request.ts", @@ -15519,7 +15275,6 @@ { "id": "aivault-resume-prepare-repin", "operation": "aiVault.resume-preparation", - "version": 1, "family": "aiVault.resume-preparation", "sites": ["mobile/src/session/ai-vault-resume-preparation.ts"], "schedules": [], @@ -15552,7 +15307,6 @@ { "id": "aivault-resume-prepare-unavailable", "operation": "aiVault.resume-preparation", - "version": 1, "family": "aiVault.resume-preparation", "sites": ["mobile/src/session/ai-vault-resume-preparation.ts"], "schedules": [], @@ -15585,7 +15339,6 @@ { "id": "aivault-resume-prepare-refused", "operation": "aiVault.resume-preparation", - "version": 1, "family": "aiVault.resume-preparation", "sites": ["mobile/src/session/ai-vault-resume-preparation.ts"], "schedules": [], @@ -15618,7 +15371,6 @@ { "id": "aivault-resume-prepare-skipped", "operation": "aiVault.resume-preparation", - "version": 1, "family": "aiVault.resume-preparation", "sites": ["mobile/src/session/ai-vault-resume-preparation.ts"], "schedules": [], @@ -15635,7 +15387,6 @@ { "id": "aivault-resume-launch-sent", "operation": "aiVault.resume-launch", - "version": 1, "family": "aiVault.resume-launch", "sites": ["mobile/src/session/ai-vault-resume-launch.ts"], "schedules": [], @@ -15694,7 +15445,6 @@ { "id": "aivault-resume-launch-locked", "operation": "aiVault.resume-launch", - "version": 1, "family": "aiVault.resume-launch", "sites": ["mobile/src/session/ai-vault-resume-launch.ts"], "schedules": [], @@ -15753,7 +15503,6 @@ { "id": "aivault-resume-launch-create-refused", "operation": "aiVault.resume-launch", - "version": 1, "family": "aiVault.resume-launch", "sites": ["mobile/src/session/ai-vault-resume-launch.ts"], "schedules": [], @@ -15786,7 +15535,6 @@ { "id": "aivault-resume-launch-invalid-tab", "operation": "aiVault.resume-launch", - "version": 1, "family": "aiVault.resume-launch", "sites": ["mobile/src/session/ai-vault-resume-launch.ts"], "schedules": [], @@ -15826,7 +15574,6 @@ { "id": "clipboard-image-upload-single-frame-fallback", "operation": "clipboard.image-upload", - "version": 1, "family": "clipboard.image-upload", "sites": ["mobile/src/session/mobile-clipboard-image.ts"], "schedules": [], @@ -15868,7 +15615,6 @@ { "id": "clipboard-image-upload-chunked", "operation": "clipboard.image-upload", - "version": 1, "family": "clipboard.image-upload", "sites": ["mobile/src/session/mobile-clipboard-image.ts"], "schedules": [], @@ -15922,7 +15668,6 @@ { "id": "clipboard-image-upload-aborts-on-chunk-failure", "operation": "clipboard.image-upload", - "version": 1, "family": "clipboard.image-upload", "sites": ["mobile/src/session/mobile-clipboard-image.ts"], "schedules": [], @@ -15979,7 +15724,6 @@ { "id": "clipboard-image-upload-start-refused", "operation": "clipboard.image-upload", - "version": 1, "family": "clipboard.image-upload", "sites": ["mobile/src/session/mobile-clipboard-image.ts"], "schedules": [], @@ -16010,7 +15754,6 @@ { "id": "clipboard-image-attachment-upload-refused", "operation": "clipboard.image-terminal-attachment", - "version": 1, "family": "clipboard.image-attachment", "sites": [ "mobile/src/session/mobile-image-attachment.ts", @@ -16044,7 +15787,6 @@ { "id": "clipboard-image-attachment-pasted", "operation": "clipboard.image-terminal-attachment", - "version": 1, "family": "clipboard.image-attachment", "sites": [ "mobile/src/session/mobile-image-attachment.ts", @@ -16121,7 +15863,6 @@ { "id": "clipboard-image-attachment-cancelled", "operation": "clipboard.image-terminal-attachment", - "version": 1, "family": "clipboard.image-attachment", "sites": [ "mobile/src/session/mobile-image-attachment.ts", @@ -16141,7 +15882,6 @@ { "id": "clipboard-image-attachment-blocked-before-send", "operation": "clipboard.image-terminal-attachment", - "version": 1, "family": "clipboard.image-attachment", "sites": [ "mobile/src/session/mobile-image-attachment.ts", @@ -16198,7 +15938,6 @@ { "id": "clipboard-image-attachment-anonymous", "operation": "clipboard.image-terminal-attachment", - "version": 1, "family": "clipboard.image-attachment", "sites": [ "mobile/src/session/mobile-image-attachment.ts", @@ -16271,7 +16010,6 @@ { "id": "native-chat-image-paste-single", "operation": "nativeChat.image-paste", - "version": 1, "family": "nativeChat.image-paste", "sites": ["mobile/src/session/mobile-native-chat-image-send.ts"], "schedules": [], @@ -16328,7 +16066,6 @@ { "id": "native-chat-image-paste-two-images", "operation": "nativeChat.image-paste", - "version": 1, "family": "nativeChat.image-paste", "sites": ["mobile/src/session/mobile-native-chat-image-send.ts"], "schedules": [], @@ -16405,7 +16142,6 @@ { "id": "native-chat-image-paste-stops-on-rejection", "operation": "nativeChat.image-paste", - "version": 1, "family": "nativeChat.image-paste", "sites": ["mobile/src/session/mobile-native-chat-image-send.ts"], "schedules": [], @@ -16462,7 +16198,6 @@ { "id": "native-chat-image-paste-trailing-image", "operation": "nativeChat.image-paste", - "version": 1, "family": "nativeChat.image-paste", "sites": ["mobile/src/session/mobile-native-chat-image-send.ts"], "schedules": [], @@ -16519,7 +16254,6 @@ { "id": "native-chat-write-accepted", "operation": "nativeChat.terminal-write", - "version": 1, "family": "nativeChat.terminal-write", "sites": ["mobile/src/session/mobile-native-chat-send.ts"], "schedules": [], @@ -16568,7 +16302,6 @@ { "id": "native-chat-write-rejected", "operation": "nativeChat.terminal-write", - "version": 1, "family": "nativeChat.terminal-write", "sites": ["mobile/src/session/mobile-native-chat-send.ts"], "schedules": [], @@ -16605,7 +16338,6 @@ { "id": "native-chat-write-delivery-unknown", "operation": "nativeChat.terminal-write", - "version": 1, "family": "nativeChat.terminal-write", "sites": ["mobile/src/session/mobile-native-chat-send.ts"], "schedules": [], @@ -16638,7 +16370,6 @@ { "id": "native-chat-write-clear-line", "operation": "nativeChat.terminal-write", - "version": 1, "family": "nativeChat.terminal-write", "sites": ["mobile/src/session/mobile-native-chat-send.ts"], "schedules": [], @@ -16675,7 +16406,6 @@ { "id": "native-chat-write-typed-command", "operation": "nativeChat.terminal-write", - "version": 1, "family": "nativeChat.terminal-write", "sites": ["mobile/src/session/mobile-native-chat-send.ts"], "schedules": [], @@ -16793,7 +16523,6 @@ { "id": "native-chat-session-option-pick-written", "operation": "nativeChat.session-option-pick", - "version": 1, "family": "nativeChat.session-option-pick", "sites": ["mobile/src/session/mobile-native-chat-session-option-persistence.ts"], "schedules": [], @@ -16830,7 +16559,6 @@ { "id": "native-chat-session-option-pick-refused", "operation": "nativeChat.session-option-pick", - "version": 1, "family": "nativeChat.session-option-pick", "sites": ["mobile/src/session/mobile-native-chat-session-option-persistence.ts"], "schedules": [], @@ -16868,7 +16596,6 @@ { "id": "native-chat-session-option-pick-empty", "operation": "nativeChat.session-option-pick", - "version": 1, "family": "nativeChat.session-option-pick", "sites": ["mobile/src/session/mobile-native-chat-session-option-persistence.ts"], "schedules": [], @@ -16885,7 +16612,6 @@ { "id": "session-tab-activation-focus-and-activate", "operation": "session.tab-activation", - "version": 1, "family": "session.tab-activation", "sites": ["mobile/src/session/mobile-session-tab-activation.ts"], "schedules": [], @@ -16935,7 +16661,6 @@ { "id": "session-tab-activation-refused", "operation": "session.tab-activation", - "version": 1, "family": "session.tab-activation", "sites": ["mobile/src/session/mobile-session-tab-activation.ts"], "schedules": [], @@ -16969,7 +16694,6 @@ { "id": "session-tab-activation-transport-error", "operation": "session.tab-activation", - "version": 1, "family": "session.tab-activation", "sites": ["mobile/src/session/mobile-session-tab-activation.ts"], "schedules": [], @@ -16996,7 +16720,6 @@ { "id": "session-tabs-health-reconciled", "operation": "session.tabs-stream-health", - "version": 1, "family": "session.tabs-stream-health", "sites": ["mobile/src/session/mobile-session-tabs-stream-health.ts"], "schedules": [], @@ -17033,7 +16756,6 @@ { "id": "session-tabs-health-refused", "operation": "session.tabs-stream-health", - "version": 1, "family": "session.tabs-stream-health", "sites": ["mobile/src/session/mobile-session-tabs-stream-health.ts"], "schedules": [], @@ -17067,7 +16789,6 @@ { "id": "session-tabs-health-stale-application-revision", "operation": "session.tabs-stream-health", - "version": 1, "family": "session.tabs-stream-health", "sites": ["mobile/src/session/mobile-session-tabs-stream-health.ts"], "schedules": [], @@ -17108,7 +16829,6 @@ { "id": "session-tabs-health-errored", "operation": "session.tabs-stream-health", - "version": 1, "family": "session.tabs-stream-health", "sites": ["mobile/src/session/mobile-session-tabs-stream-health.ts"], "schedules": [], @@ -17139,7 +16859,6 @@ { "id": "file-tap-opens-worktree-file", "operation": "files.terminal-path-tap", - "version": 1, "family": "files.terminal-path-tap", "sites": ["mobile/src/session/mobile-file-tap-open.ts"], "schedules": [], @@ -17206,7 +16925,6 @@ { "id": "file-tap-resolve-miss", "operation": "files.terminal-path-tap", - "version": 1, "family": "files.terminal-path-tap", "sites": ["mobile/src/session/mobile-file-tap-open.ts"], "schedules": [], @@ -17240,7 +16958,6 @@ { "id": "file-tap-resolve-refused", "operation": "files.terminal-path-tap", - "version": 1, "family": "files.terminal-path-tap", "sites": ["mobile/src/session/mobile-file-tap-open.ts"], "schedules": [], @@ -17274,7 +16991,6 @@ { "id": "file-tap-open-refused", "operation": "files.terminal-path-tap", - "version": 1, "family": "files.terminal-path-tap", "sites": ["mobile/src/session/mobile-file-tap-open.ts"], "schedules": [], @@ -17329,7 +17045,6 @@ { "id": "file-tap-previews-absolute-artifact", "operation": "files.terminal-path-tap", - "version": 1, "family": "files.terminal-path-tap", "sites": ["mobile/src/session/mobile-file-tap-open.ts"], "schedules": [], @@ -17368,7 +17083,6 @@ { "id": "structured-launch-unsupported", "operation": "agentSession.structured-launch", - "version": 1, "family": "agentSession.structured-launch", "sites": ["mobile/src/session/mobile-structured-agent-session-launch.ts"], "schedules": [], @@ -17399,7 +17113,6 @@ { "id": "structured-launch-support-refused", "operation": "agentSession.structured-launch", - "version": 1, "family": "agentSession.structured-launch", "sites": ["mobile/src/session/mobile-structured-agent-session-launch.ts"], "schedules": [], @@ -17430,7 +17143,6 @@ { "id": "structured-launch-created", "operation": "agentSession.structured-launch", - "version": 1, "family": "agentSession.structured-launch", "sites": ["mobile/src/session/mobile-structured-agent-session-launch.ts"], "schedules": [], @@ -17482,7 +17194,6 @@ { "id": "structured-launch-definitive-refusal", "operation": "agentSession.structured-launch", - "version": 1, "family": "agentSession.structured-launch", "sites": ["mobile/src/session/mobile-structured-agent-session-launch.ts"], "schedules": [], @@ -17535,7 +17246,6 @@ { "id": "structured-launch-replays-dropped-create", "operation": "agentSession.structured-launch", - "version": 1, "family": "agentSession.structured-launch", "sites": ["mobile/src/session/mobile-structured-agent-session-launch.ts"], "schedules": [], @@ -17604,7 +17314,6 @@ { "id": "session-markdown-tab-read", "operation": "session.tab-documents", - "version": 1, "family": "session.tab-documents", "sites": ["mobile/src/session/use-mobile-session-document-readers.ts"], "schedules": [], @@ -17637,7 +17346,6 @@ { "id": "session-markdown-tab-disk-fallback", "operation": "session.tab-documents", - "version": 1, "family": "session.tab-documents", "sites": ["mobile/src/session/use-mobile-session-document-readers.ts"], "schedules": [], @@ -17683,7 +17391,6 @@ { "id": "session-markdown-tab-refused", "operation": "session.tab-documents", - "version": 1, "family": "session.tab-documents", "sites": ["mobile/src/session/use-mobile-session-document-readers.ts"], "schedules": [], @@ -17714,7 +17421,6 @@ { "id": "session-file-tab-read", "operation": "session.tab-documents", - "version": 1, "family": "session.tab-documents", "sites": ["mobile/src/session/use-mobile-session-document-readers.ts"], "schedules": [], @@ -17746,7 +17452,6 @@ { "id": "session-terminal-list-merged", "operation": "session.terminal-inventory", - "version": 1, "family": "session.terminal-inventory", "sites": ["mobile/src/session/use-mobile-session-terminal-list.ts"], "schedules": [], @@ -17785,7 +17490,6 @@ { "id": "session-terminal-list-dedupes-handles", "operation": "session.terminal-inventory", - "version": 1, "family": "session.terminal-inventory", "sites": ["mobile/src/session/use-mobile-session-terminal-list.ts"], "schedules": [], @@ -17824,7 +17528,6 @@ { "id": "session-terminal-list-refused", "operation": "session.terminal-inventory", - "version": 1, "family": "session.terminal-inventory", "sites": ["mobile/src/session/use-mobile-session-terminal-list.ts"], "schedules": [], @@ -17855,7 +17558,6 @@ { "id": "session-terminal-list-empty-guarded", "operation": "session.terminal-inventory", - "version": 1, "family": "session.terminal-inventory", "sites": ["mobile/src/session/use-mobile-session-terminal-list.ts"], "schedules": [], @@ -17885,7 +17587,6 @@ { "id": "native-chat-readability-local-repo", "operation": "session.native-chat-readability", - "version": 1, "family": "session.native-chat-readability", "sites": ["mobile/src/session/use-mobile-native-chat-readability.ts"], "schedules": [], @@ -17919,7 +17620,6 @@ { "id": "native-chat-readability-remote-repo", "operation": "session.native-chat-readability", - "version": 1, "family": "session.native-chat-readability", "sites": ["mobile/src/session/use-mobile-native-chat-readability.ts"], "schedules": [], @@ -17953,7 +17653,6 @@ { "id": "native-chat-readability-refused", "operation": "session.native-chat-readability", - "version": 1, "family": "session.native-chat-readability", "sites": ["mobile/src/session/use-mobile-native-chat-readability.ts"], "schedules": [], @@ -17983,7 +17682,6 @@ { "id": "native-chat-stop-accepted", "operation": "session.native-chat-stop", - "version": 1, "family": "session.native-chat-stop", "sites": ["mobile/src/session/use-mobile-native-chat-stop.ts"], "schedules": [], @@ -18057,7 +17755,6 @@ { "id": "native-chat-stop-both-rejected", "operation": "session.native-chat-stop", - "version": 1, "family": "session.native-chat-stop", "sites": ["mobile/src/session/use-mobile-native-chat-stop.ts"], "schedules": [], @@ -18116,7 +17813,6 @@ { "id": "native-chat-stop-delivery-unknown", "operation": "session.native-chat-stop", - "version": 1, "family": "session.native-chat-stop", "sites": ["mobile/src/session/use-mobile-native-chat-stop.ts"], "schedules": [], @@ -18167,7 +17863,6 @@ { "id": "session-create-markdown-note", "operation": "session.content-create", - "version": 1, "family": "session.content-create", "sites": ["mobile/src/session/use-mobile-session-content-create-actions.ts"], "schedules": [], @@ -18240,7 +17935,6 @@ { "id": "session-create-markdown-name-collision", "operation": "session.content-create", - "version": 1, "family": "session.content-create", "sites": ["mobile/src/session/use-mobile-session-content-create-actions.ts"], "schedules": [], @@ -18328,7 +18022,6 @@ { "id": "session-create-browser-tab", "operation": "session.content-create", - "version": 1, "family": "session.content-create", "sites": ["mobile/src/session/use-mobile-session-content-create-actions.ts"], "schedules": [], @@ -18362,7 +18055,6 @@ { "id": "session-create-browser-refused", "operation": "session.content-create", - "version": 1, "family": "session.content-create", "sites": ["mobile/src/session/use-mobile-session-content-create-actions.ts"], "schedules": [], @@ -18394,7 +18086,6 @@ { "id": "session-tab-close-terminal", "operation": "session.tab-close", - "version": 1, "family": "session.tab-close", "sites": ["mobile/src/session/use-mobile-session-close-actions.ts"], "schedules": [], @@ -18423,7 +18114,6 @@ { "id": "session-tab-close-refused-keeps-tab", "operation": "session.tab-close", - "version": 1, "family": "session.tab-close", "sites": ["mobile/src/session/use-mobile-session-close-actions.ts"], "schedules": [], @@ -18453,7 +18143,6 @@ { "id": "session-tab-rename", "operation": "session.tab-close", - "version": 1, "family": "session.tab-close", "sites": ["mobile/src/session/use-mobile-session-close-actions.ts"], "schedules": [], @@ -18489,7 +18178,6 @@ { "id": "session-tab-close-session-tab", "operation": "session.tab-close", - "version": 1, "family": "session.tab-close", "sites": ["mobile/src/session/use-mobile-session-close-actions.ts"], "schedules": [], @@ -18520,7 +18208,6 @@ { "id": "review-mark-reviewed-persists", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18587,7 +18274,6 @@ { "id": "review-mark-reviewed-rolls-back", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18655,7 +18341,6 @@ { "id": "review-stage-file", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18691,7 +18376,6 @@ { "id": "review-stage-refused", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18728,7 +18412,6 @@ { "id": "review-open-in-session", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18765,7 +18448,6 @@ { "id": "review-send-notes-heals-stale-input", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18808,7 +18490,6 @@ { "id": "review-create-agent-refused", "operation": "session.diff-review-actions", - "version": 1, "family": "session.diff-review-actions", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -18895,7 +18576,6 @@ { "id": "session-diff-notes-loaded", "operation": "session.diff-notes", - "version": 1, "family": "session.diff-notes", "sites": ["mobile/src/session/use-mobile-session-diff-comments.ts"], "schedules": [], @@ -18935,7 +18615,6 @@ { "id": "session-diff-notes-load-refused", "operation": "session.diff-notes", - "version": 1, "family": "session.diff-notes", "sites": ["mobile/src/session/use-mobile-session-diff-comments.ts"], "schedules": [], @@ -18965,7 +18644,6 @@ { "id": "session-markdown-saved", "operation": "session.markdown-save", - "version": 1, "family": "session.markdown-save", "sites": ["mobile/src/session/use-mobile-session-markdown-actions.ts"], "schedules": [], @@ -18999,7 +18677,6 @@ { "id": "session-markdown-save-conflict", "operation": "session.markdown-save", - "version": 1, "family": "session.markdown-save", "sites": ["mobile/src/session/use-mobile-session-markdown-actions.ts"], "schedules": [], @@ -19032,7 +18709,6 @@ { "id": "quick-commands-loaded-and-saved", "operation": "settings.quick-commands", - "version": 1, "family": "settings.quick-commands", "sites": ["mobile/src/session/use-quick-commands.ts"], "schedules": [], @@ -19092,7 +18768,6 @@ { "id": "quick-commands-save-refused-rolls-back", "operation": "settings.quick-commands", - "version": 1, "family": "settings.quick-commands", "sites": ["mobile/src/session/use-quick-commands.ts"], "schedules": [], @@ -19146,7 +18821,6 @@ { "id": "quick-commands-load-refused", "operation": "settings.quick-commands", - "version": 1, "family": "settings.quick-commands", "sites": ["mobile/src/session/use-quick-commands.ts"], "schedules": [], @@ -19176,7 +18850,6 @@ { "id": "native-chat-image-upload-start-refused", "operation": "nativeChat.image-upload", - "version": 1, "family": "nativeChat.image-upload", "sites": ["mobile/src/session/mobile-native-chat-image-attachment.ts"], "schedules": [], @@ -19207,7 +18880,6 @@ { "id": "native-chat-image-upload-single", "operation": "nativeChat.image-upload", - "version": 1, "family": "nativeChat.image-upload", "sites": ["mobile/src/session/mobile-native-chat-image-attachment.ts"], "schedules": [], @@ -19261,7 +18933,6 @@ { "id": "native-chat-image-upload-two", "operation": "nativeChat.image-upload", - "version": 1, "family": "nativeChat.image-upload", "sites": ["mobile/src/session/mobile-native-chat-image-attachment.ts"], "schedules": [], @@ -19352,7 +19023,6 @@ { "id": "native-chat-image-upload-second-fails", "operation": "nativeChat.image-upload", - "version": 1, "family": "nativeChat.image-upload", "sites": ["mobile/src/session/mobile-native-chat-image-attachment.ts"], "schedules": [], @@ -19420,7 +19090,6 @@ { "id": "native-chat-image-upload-cancelled", "operation": "nativeChat.image-upload", - "version": 1, "family": "nativeChat.image-upload", "sites": ["mobile/src/session/mobile-native-chat-image-attachment.ts"], "schedules": [], @@ -19437,7 +19106,6 @@ { "id": "files-explorer-legacy-fallback", "operation": "files.explorer-screen", - "version": 1, "family": "files.explorer-screen", "sites": ["mobile/src/files/MobileFileExplorerPanel.tsx"], "schedules": [], @@ -19496,7 +19164,6 @@ { "id": "files-explorer-readdir", "operation": "files.explorer-screen", - "version": 1, "family": "files.explorer-screen", "sites": ["mobile/src/files/MobileFileExplorerPanel.tsx"], "schedules": [], @@ -19536,7 +19203,6 @@ { "id": "new-workspace-repositories-fulfilled", "operation": "workspace.repositories", - "version": 1, "family": "components.new-workspace-repositories", "sites": ["mobile/src/components/use-new-workspace-repositories.ts"], "schedules": [], @@ -19582,7 +19248,6 @@ { "id": "codex-reset-credit-consumed", "operation": "accounts.codex-reset-credit", - "version": 1, "family": "components.codex-reset-credit", "sites": [ "mobile/src/components/codex-reset-credit.ts", @@ -19672,7 +19337,6 @@ { "id": "codex-reset-credit-resumed", "operation": "accounts.codex-reset-credit", - "version": 1, "family": "components.codex-reset-credit", "sites": ["mobile/src/storage/codex-reset-attempt-journal.ts"], "schedules": [], @@ -19761,7 +19425,6 @@ { "id": "push-dismissal-tray-reconciled", "operation": "notifications.push-dismissal", - "version": 1, "family": "notifications.push-dismissal", "sites": ["mobile/src/notifications/push-dismissal-reconciliation.ts"], "schedules": [], @@ -19827,7 +19490,6 @@ { "id": "terminal-paste-accepted", "operation": "session.terminal-clipboard", - "version": 1, "family": "session.terminal-paste", "sites": [ "mobile/src/session/use-mobile-terminal-paste.ts", @@ -19908,7 +19570,6 @@ { "id": "terminal-paste-refused", "operation": "session.terminal-clipboard", - "version": 1, "family": "session.terminal-paste", "sites": ["mobile/src/session/use-mobile-terminal-paste.ts"], "schedules": [], @@ -19970,7 +19631,6 @@ { "id": "terminal-worktree-connection-resolved", "operation": "session.terminal-clipboard", - "version": 1, "family": "session.worktree-connection", "sites": ["mobile/src/session/use-mobile-session-accessory-selection.ts"], "schedules": [], @@ -20028,7 +19688,6 @@ { "id": "terminal-input-send-accepted", "operation": "session.terminal-input-send", - "version": 1, "family": "session.terminal-input-send", "sites": [ "mobile/src/session/use-mobile-session-terminal-send-actions.ts", @@ -20094,7 +19753,6 @@ { "id": "terminal-input-send-refused", "operation": "session.terminal-input-send", - "version": 1, "family": "session.terminal-input-send", "sites": ["mobile/src/session/use-mobile-session-terminal-send-actions.ts"], "schedules": [], @@ -20142,7 +19800,6 @@ { "id": "terminal-live-input-accepted", "operation": "session.terminal-input-send", - "version": 1, "family": "session.terminal-input-send", "sites": ["mobile/src/session/use-mobile-session-terminal-send-actions.ts"], "schedules": [], @@ -20198,7 +19855,6 @@ { "id": "home-host-accounts", "operation": "home.host-accounts", - "version": 1, "family": "home.host-accounts", "sites": ["mobile/src/home/mobile-home-host-requests.ts"], "schedules": [], @@ -20249,7 +19905,6 @@ { "id": "notifications-display-test-accepted", "operation": "notifications.display-test-screen", - "version": 1, "family": "notifications.display-test-screen", "sites": ["mobile/src/settings/notification-display-test.tsx"], "schedules": [], @@ -20289,7 +19944,6 @@ { "id": "notifications-display-test-not-registered", "operation": "notifications.display-test-screen", - "version": 1, "family": "notifications.display-test-screen", "sites": ["mobile/src/settings/notification-display-test.tsx"], "schedules": [], @@ -20330,7 +19984,6 @@ { "id": "notifications-display-test-rate-limited", "operation": "notifications.display-test-screen", - "version": 1, "family": "notifications.display-test-screen", "sites": ["mobile/src/settings/notification-display-test.tsx"], "schedules": [], @@ -20371,7 +20024,6 @@ { "id": "notifications-display-test-unknown-reason", "operation": "notifications.display-test-screen", - "version": 1, "family": "notifications.display-test-screen", "sites": ["mobile/src/settings/notification-display-test.tsx"], "schedules": [], @@ -20412,7 +20064,6 @@ { "id": "aivault-history-screen-worktrees", "operation": "aiVault.history-screen", - "version": 1, "family": "aiVault.history-screen", "sites": ["mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx"], "schedules": [], @@ -20487,7 +20138,6 @@ { "id": "aivault-history-screen-listed", "operation": "aiVault.history-screen", - "version": 1, "family": "aiVault.history-screen", "sites": ["mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx"], "schedules": [], @@ -20605,7 +20255,6 @@ { "id": "tasks-route-repo-list", "operation": "tasks.route-repo-list", - "version": 1, "family": "tasks.route-repo-list", "sites": ["mobile/src/tasks/use-mobile-tasks-route-and-item-state.tsx"], "schedules": [], @@ -20653,7 +20302,6 @@ { "id": "linear-select-workspace", "operation": "linear.select-workspace-picker", - "version": 1, "family": "linear.select-workspace-picker", "sites": ["mobile/src/tasks/mobile-tasks-filter-pickers.tsx"], "schedules": [], @@ -20688,7 +20336,6 @@ { "id": "live-worktree-name-stream", "operation": "session.live-worktree-name", - "version": 1, "family": "live-worktree-name", "sites": ["mobile/src/session/use-live-worktree-name.ts"], "schedules": [], @@ -20812,7 +20459,6 @@ { "id": "host-worktree-refresh-stream", "operation": "worktree.host-refresh", - "version": 1, "family": "host-worktree-refresh", "sites": ["mobile/src/worktree/host-worktree-refresh.ts"], "schedules": [], @@ -20908,7 +20554,6 @@ { "id": "sc-changes-loaded", "operation": "source-control.changes-load", - "version": 1, "family": "git.changes-load", "sites": ["mobile/src/source-control/use-mobile-source-control-loaders.ts"], "schedules": [], @@ -20994,10 +20639,10 @@ "result": { "summary": { "baseRef": "origin/dev", - "baseOid": "base-oid", + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "compareRef": "feature", - "headOid": "head-oid", - "mergeBase": "merge-base", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567", "changedFiles": 1, "status": "ready" }, @@ -21020,7 +20665,6 @@ { "id": "sc-history-commit-files", "operation": "source-control.history-commit-files", - "version": 1, "family": "git.history-commit-files", "sites": ["mobile/src/source-control/MobileGitHistoryList.tsx"], "schedules": [], @@ -21094,7 +20738,6 @@ { "id": "sc-branch-diff-previewed", "operation": "source-control.branch-diff-preview", - "version": 1, "family": "git.branch-diff-preview", "sites": ["mobile/src/source-control/use-mobile-source-control-openers.ts"], "schedules": [], @@ -21117,9 +20760,9 @@ "filePath": "src/app.ts", "compare": { "baseRef": "origin/main", - "baseOid": "base-oid", - "headOid": "head-oid", - "mergeBase": "merge-base" + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" } }, "reply": { @@ -21139,7 +20782,6 @@ { "id": "notifications-desktop-stream", "operation": "notifications.desktop-stream", - "version": 1, "family": "notifications.desktop-stream", "sites": ["mobile/src/notifications/mobile-notifications.ts"], "schedules": [], @@ -21282,7 +20924,6 @@ { "id": "notifications-desktop-stream-replayed", "operation": "notifications.desktop-stream", - "version": 1, "family": "notifications.desktop-stream", "sites": ["mobile/src/notifications/mobile-notifications.ts"], "schedules": [], @@ -21445,7 +21086,6 @@ { "id": "native-chat-page-earlier", "operation": "session.native-chat-page", - "version": 1, "family": "session.native-chat-page", "sites": ["mobile/src/session/use-mobile-native-chat-session.ts"], "schedules": [], @@ -21463,7 +21103,7 @@ "agent": "claude", "sessionId": "session-1", "limit": 40, - "subscriptionId": "claude:session-1", + "subscriptionId": "claude:session-1:00000000-0000-4000-8000-000000000001", "capabilities": { "transcriptPending": 1 }, @@ -21574,7 +21214,7 @@ "agent": "claude", "sessionId": "session-1", "limit": 40, - "subscriptionId": "claude:session-1", + "subscriptionId": "claude:session-1:00000000-0000-4000-8000-000000000001", "capabilities": { "transcriptPending": 1 }, @@ -21643,7 +21283,6 @@ { "id": "terminal-gesture-flush-and-clear", "operation": "session.terminal-gesture-input", - "version": 1, "family": "session.terminal-gesture-input", "sites": ["mobile/src/session/use-mobile-session-terminal-input.ts"], "schedules": [], @@ -21730,7 +21369,6 @@ { "id": "session-startup-both-activation-sites", "operation": "session.startup", - "version": 1, "family": "session.startup", "sites": ["mobile/src/session/use-mobile-session-startup.ts"], "schedules": [], @@ -21809,7 +21447,6 @@ { "id": "session-startup-floating-route-skips-activation", "operation": "session.startup", - "version": 1, "family": "session.startup", "sites": ["mobile/src/session/use-mobile-session-startup.ts"], "schedules": [], @@ -21836,7 +21473,6 @@ { "id": "session-startup-keeps-terminals-visible-on-reconnect", "operation": "session.startup", - "version": 1, "family": "session.startup", "sites": ["mobile/src/session/use-mobile-session-startup.ts"], "schedules": [], @@ -21881,7 +21517,6 @@ { "id": "session-startup-refused-tab-load-still-loads-terminals", "operation": "session.startup", - "version": 1, "family": "session.startup", "sites": ["mobile/src/session/use-mobile-session-startup.ts"], "schedules": [], @@ -21919,7 +21554,6 @@ { "id": "session-create-terminal-with-prompt", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22005,7 +21639,6 @@ { "id": "session-create-terminal-replaces-active", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22062,7 +21695,6 @@ { "id": "session-create-terminal-refused", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22111,7 +21743,6 @@ { "id": "session-create-terminal-without-handle", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22168,7 +21799,6 @@ { "id": "session-create-terminal-without-active-tab", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22226,7 +21856,6 @@ { "id": "session-create-terminal-runs-a-quick-command", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22290,7 +21919,6 @@ { "id": "session-create-terminal-launches-an-agent-quick-command", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22354,7 +21982,6 @@ { "id": "session-create-terminal-ignores-a-second-create-in-flight", "operation": "session.create-terminal", - "version": 1, "family": "session.create-terminal", "sites": ["mobile/src/session/use-mobile-session-terminal-create-actions.ts"], "schedules": [], @@ -22427,7 +22054,6 @@ { "id": "session-terminal-display-mode-auto-take-floor", "operation": "session.terminal-display-mode", - "version": 1, "family": "session.terminal-display-mode", "sites": ["mobile/src/session/use-mobile-session-terminal-stream-display.ts"], "schedules": [], @@ -22485,7 +22111,6 @@ { "id": "session-terminal-display-mode-to-desktop", "operation": "session.terminal-display-mode", - "version": 1, "family": "session.terminal-display-mode", "sites": ["mobile/src/session/use-mobile-session-terminal-stream-display.ts"], "schedules": [], @@ -22536,7 +22161,6 @@ { "id": "session-terminal-display-mode-drops-second-toggle", "operation": "session.terminal-display-mode", - "version": 1, "family": "session.terminal-display-mode", "sites": ["mobile/src/session/use-mobile-session-terminal-stream-display.ts"], "schedules": [], @@ -22607,7 +22231,6 @@ { "id": "session-terminal-display-mode-auto-without-viewport", "operation": "session.terminal-display-mode", - "version": 1, "family": "session.terminal-display-mode", "sites": ["mobile/src/session/use-mobile-session-terminal-stream-display.ts"], "schedules": [], @@ -22657,7 +22280,6 @@ { "id": "session-terminal-display-mode-auto-without-device-token", "operation": "session.terminal-display-mode", - "version": 1, "family": "session.terminal-display-mode", "sites": ["mobile/src/session/use-mobile-session-terminal-stream-display.ts"], "schedules": [], @@ -22710,7 +22332,6 @@ { "id": "notifications-desktop-stream-closed", "operation": "notifications.desktop-stream", - "version": 1, "family": "notifications.desktop-stream", "sites": ["mobile/src/notifications/mobile-notifications.ts"], "schedules": [], @@ -22826,7 +22447,6 @@ { "id": "review-file-diff-shapes", "operation": "session.diff-review-load", - "version": 1, "family": "session.review-file-diff", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -22909,7 +22529,6 @@ { "id": "review-branch-diff-shapes", "operation": "session.diff-review-load", - "version": 1, "family": "session.review-branch-diff", "sites": ["mobile/src/session/mobile-diff-review-loaders.ts"], "schedules": [], @@ -22928,9 +22547,9 @@ "filePath": "src/app.ts", "compare": { "baseRef": "origin/main", - "baseOid": "base-oid", - "headOid": "head-oid", - "mergeBase": "merge-base" + "baseOid": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "headOid": "f0e1d2c3b4a5968778695a4b3c2d1e0f98765432", + "mergeBase": "0123456789abcdef0123456789abcdef01234567" } }, "reply": { @@ -22959,7 +22578,6 @@ { "id": "review-git-mutations-run", "operation": "session.diff-review-actions", - "version": 1, "family": "session.review-git-mutations", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -23035,7 +22653,6 @@ { "id": "review-send-sheet-lists-terminals", "operation": "session.diff-review-actions", - "version": 1, "family": "session.review-send-sheet", "sites": [ "mobile/src/session/use-mobile-diff-review-interactions.ts", @@ -23082,7 +22699,6 @@ { "id": "session-browser-tab-created", "operation": "session.content-create", - "version": 1, "family": "session.browser-tab-create", "sites": ["mobile/src/session/use-mobile-session-content-create-actions.ts"], "schedules": [], @@ -23116,7 +22732,6 @@ { "id": "structured-agent-session-created", "operation": "agentSession.structured-launch", - "version": 1, "family": "agentSession.structured-create", "sites": ["mobile/src/session/mobile-structured-agent-session-launch.ts"], "schedules": [], @@ -23168,7 +22783,6 @@ { "id": "session-tab-renamed", "operation": "session.tab-close", - "version": 1, "family": "session.tab-rename", "sites": ["mobile/src/session/use-mobile-session-close-actions.ts"], "schedules": [], @@ -23204,7 +22818,6 @@ { "id": "session-tab-closed", "operation": "session.tab-close", - "version": 1, "family": "session.tab-close-session", "sites": ["mobile/src/session/use-mobile-session-close-actions.ts"], "schedules": [], @@ -23235,7 +22848,6 @@ { "id": "new-tab-local-agents", "operation": "settings.new-tab-agents", - "version": 1, "family": "settings.new-tab-local-agents", "sites": ["mobile/src/session/mobile-new-tab-agent-loader.ts"], "schedules": [], @@ -23297,7 +22909,6 @@ { "id": "files-preview-worktree-text-read", "operation": "files.preview-load", - "version": 1, "family": "files.preview-worktree-text", "sites": ["mobile/src/files/mobile-file-preview-request.ts"], "schedules": [], @@ -23329,7 +22940,6 @@ { "id": "files-preview-worktree-image-read", "operation": "files.preview-load", - "version": 1, "family": "files.preview-worktree-image", "sites": ["mobile/src/files/mobile-file-preview-request.ts"], "schedules": [], @@ -23365,7 +22975,6 @@ { "id": "files-preview-artifact-image-read", "operation": "files.preview-load", - "version": 1, "family": "files.preview-artifact-image", "sites": ["mobile/src/files/mobile-file-preview-request.ts"], "schedules": [], @@ -23402,7 +23011,6 @@ { "id": "session-markdown-disk-read", "operation": "session.tab-documents", - "version": 1, "family": "session.markdown-disk-fallback", "sites": ["mobile/src/session/use-mobile-session-document-readers.ts"], "schedules": [], @@ -23448,7 +23056,6 @@ { "id": "session-markdown-disk-served", "operation": "session.tab-documents", - "version": 1, "family": "session.markdown-disk-fallback", "sites": ["mobile/src/session/use-mobile-session-document-readers.ts"], "schedules": [], @@ -23481,7 +23088,6 @@ { "id": "mobile-web-bundle-manifest-read", "operation": "mobileWeb.bundle-manifest", - "version": 1, "family": "mobileWeb.bundle-manifest", "sites": ["mobile/src/transport/mobile-web-bundle-operations.ts"], "schedules": [], @@ -23531,7 +23137,6 @@ { "id": "mobile-web-bundle-fetch-paged", "operation": "mobileWeb.bundle-fetch", - "version": 1, "family": "mobileWeb.bundle-fetch", "sites": [ "mobile/src/transport/mobile-web-bundle-fetch.ts", @@ -23671,7 +23276,6 @@ { "id": "mobile-web-bundle-unavailable", "operation": "mobileWeb.bundle-fetch", - "version": 1, "family": "mobileWeb.bundle-fetch", "sites": [ "mobile/src/transport/mobile-web-bundle-fetch.ts", @@ -23702,7 +23306,6 @@ { "id": "mobile-web-bundle-build-changed", "operation": "mobileWeb.bundle-fetch", - "version": 1, "family": "mobileWeb.bundle-fetch", "sites": [ "mobile/src/transport/mobile-web-bundle-fetch.ts", diff --git a/mobile/scripts/check-tests-typecheck-ratchet.mjs b/mobile/scripts/check-tests-typecheck-ratchet.mjs index f82e5c1a278..5a28034b7bd 100644 --- a/mobile/scripts/check-tests-typecheck-ratchet.mjs +++ b/mobile/scripts/check-tests-typecheck-ratchet.mjs @@ -22,10 +22,6 @@ const ERROR_LINE = /^(\S.*?)\(\d+,\d+\): error TS\d+:/ // in it: the error diff below sees a file only once it errors, so an excluded or shadowed test // disappears from this gate silently. export const TESTS_OUTSIDE_PROGRAM = new Map([ - [ - 'scripts/rpc-recording-pin-guard.test.ts', - 'Node-side: imports the desktop main process, checked against @types/node rather than RN libs' - ], [ 'src/tasks/agent-launch-mobile-replay.test.ts', 'Node-side: imports the desktop main process, checked against @types/node rather than RN libs' @@ -34,6 +30,10 @@ export const TESTS_OUTSIDE_PROGRAM = new Map([ 'src/tasks/mobile-agent-launch-architecture.test.ts', 'Node-side: imports the desktop main process, checked against @types/node rather than RN libs' ], + [ + 'src/test-support/rpc-recording/recorded-request-params.test.ts', + 'Node-side: imports the desktop RPC dispatcher, checked against @types/node rather than RN libs' + ], [ 'src/transport/mobile-relay-browser-cancel-budget.test.ts', 'Node-side: imports src/shared/child-process, checked against @types/node rather than RN libs' diff --git a/mobile/scripts/rpc-diff-report.test.ts b/mobile/scripts/rpc-diff-report.test.ts new file mode 100644 index 00000000000..797adcdacdc --- /dev/null +++ b/mobile/scripts/rpc-diff-report.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { diffGoldenSets, formatChangeMarkdown, formatChangeText } from './rpc-diff-report' +import { + decodeGoldenFile, + type DecodedGolden +} from '../src/test-support/rpc-recording/golden-difference' +import { internRecording } from '../src/test-support/rpc-recording/golden-value-pool' +import type { Recording } from '../src/test-support/rpc-recording/recording-scenario' +import type { RecordedValue } from '../src/test-support/rpc-recording/recording-values' + +function checkpoint(id: string, sender: RecordedValue[], state: RecordedValue = null) { + return { id, observation: { sender, payloads: [], settlements: {}, state, effects: [] } } +} +function golden(recording: Recording, family = 'fam'): DecodedGolden { + return { operation: 'op', family, namedDeltas: [], recording } +} +const sent = (method: string) => ({ name: `${method}#1`, method }) + +describe('golden change report', () => { + it('pairs a repeated checkpoint id by occurrence, not by its last appearance', () => { + const recording: Recording = { + scenario: 's', + checkpoints: [checkpoint('load', [sent('a')]), checkpoint('load', [sent('a'), sent('b')])] + } + const set = diffGoldenSets( + 'base', + new Map([['g', golden(recording)]]), + new Map([['g', golden(recording)]]) + ) + expect(set.changed).toEqual([]) + }) + + it('groups one moved entry across the checkpoints that re-state it', () => { + const before: Recording = { + scenario: 's', + checkpoints: [checkpoint('one', [sent('a')]), checkpoint('two', [sent('a'), sent('c')])] + } + const after: Recording = { + scenario: 's', + checkpoints: [checkpoint('one', [sent('b')]), checkpoint('two', [sent('b'), sent('c')])] + } + const set = diffGoldenSets( + 'base', + new Map([['g', golden(before)]]), + new Map([['g', golden(after)]]) + ) + expect(set.changed).toHaveLength(1) + expect(set.changed[0]!.differences).toEqual([ + { + field: 'sender', + path: '[0].method', + expected: 'a', + actual: 'b', + checkpoints: ['one', 'two'] + } + ]) + expect(formatChangeText(set)).toContain( + 'checkpoint one field sender[0].method (and 1 later checkpoint)' + ) + }) + + it('names identity, checkpoint-list, added and removed changes', () => { + const recording: Recording = { scenario: 's', checkpoints: [checkpoint('one', [])] } + const moved: Recording = { scenario: 's', checkpoints: [checkpoint('two', [])] } + const set = diffGoldenSets( + 'base', + new Map([ + ['kept', golden(recording)], + ['gone', golden(recording)] + ]), + new Map([ + ['kept', { ...golden(moved), family: 'other' }], + ['new', golden(recording)] + ]) + ) + const text = formatChangeText(set) + expect(text).toContain('family: "fam" -> "other"') + expect(text).toContain('checkpoints no longer recorded: one') + expect(text).toContain('checkpoints newly recorded: two') + expect(set.added).toEqual([{ id: 'new', family: 'fam' }]) + expect(set.removed).toEqual([{ id: 'gone', family: 'fam' }]) + expect(formatChangeMarkdown(set)).toContain('### Removed goldens (1)') + }) + + it('stops details at the byte cap and still names every changed golden', () => { + const big = 'x'.repeat(2_000) + const before = new Map() + const after = new Map() + for (let index = 0; index < 200; index += 1) { + const id = `g${String(index).padStart(3, '0')}` + before.set(id, golden({ scenario: id, checkpoints: [checkpoint('c', [], `${big}a`)] })) + after.set(id, golden({ scenario: id, checkpoints: [checkpoint('c', [], `${big}b`)] })) + } + const markdown = formatChangeMarkdown(diffGoldenSets('base', before, after), 20_000) + expect(markdown.length).toBeLessThan(40_000) + expect(markdown).toContain('_Details stop here') + expect(markdown).toContain('- g199') + }) + + it('decodes a pooled file of any format version', () => { + const recording: Recording = { scenario: 's', checkpoints: [checkpoint('one', [sent('a')])] } + const interned = internRecording(recording) + const file = { + goldenFormatVersion: 5, + baseline: 'x', + operation: 'op', + family: 'fam', + namedDeltas: [], + ...interned + } + expect(decodeGoldenFile(file, 'g').recording).toEqual(recording) + }) +}) diff --git a/mobile/scripts/rpc-diff-report.ts b/mobile/scripts/rpc-diff-report.ts new file mode 100644 index 00000000000..89ba50a21c5 --- /dev/null +++ b/mobile/scripts/rpc-diff-report.ts @@ -0,0 +1,180 @@ +import { + checkpointListDifference, + excerpt, + groupDifferences, + identityDifferences, + recordingDifferences, + type DecodedGolden, + type GroupedDifference +} from '../src/test-support/rpc-recording/golden-difference' + +export type GoldenChange = { + id: string + family: string + identity: ReturnType + checkpoints: ReturnType + differences: GroupedDifference[] +} +export type GoldenChangeSet = { + base: string + total: number + changed: GoldenChange[] + added: { id: string; family: string }[] + removed: { id: string; family: string }[] +} + +export function diffGoldenSets( + base: string, + before: ReadonlyMap, + after: ReadonlyMap +): GoldenChangeSet { + const changed: GoldenChange[] = [] + for (const [id, golden] of after) { + const previous = before.get(id) + if (!previous) { + continue + } + const identity = identityDifferences(previous, golden) + const checkpoints = checkpointListDifference(previous.recording, golden.recording) + const differences = groupDifferences(recordingDifferences(previous.recording, golden.recording)) + if ( + identity.length || + checkpoints.missing.length || + checkpoints.extra.length || + checkpoints.reordered || + differences.length + ) { + changed.push({ id, family: golden.family, identity, checkpoints, differences }) + } + } + const only = ( + from: ReadonlyMap, + other: ReadonlyMap + ) => + [...from] + .filter(([id]) => !other.has(id)) + .map(([id, golden]) => ({ id, family: golden.family })) + const byFamily = (items: T[]): T[] => + items.sort( + (left, right) => left.family.localeCompare(right.family) || left.id.localeCompare(right.id) + ) + return { + base, + total: after.size, + changed: byFamily(changed), + added: byFamily(only(after, before)), + removed: byFamily(only(before, after)) + } +} + +export function changeSetIsEmpty(set: GoldenChangeSet): boolean { + return !set.changed.length && !set.added.length && !set.removed.length +} + +function headline(set: GoldenChangeSet): string { + return `${set.changed.length} changed, ${set.added.length} added, ${set.removed.length} removed (${set.total} goldens now)` +} + +/** One golden's changes as lines, the first `limit` grouped differences in full. */ +export function describeGoldenChange(change: GoldenChange, limit: number, width = 600): string[] { + const lines: string[] = [] + for (const moved of change.identity) { + lines.push( + `${moved.field}: ${JSON.stringify(moved.expected)} -> ${JSON.stringify(moved.actual)}` + ) + } + if (change.checkpoints.missing.length) { + lines.push(`checkpoints no longer recorded: ${change.checkpoints.missing.join(', ')}`) + } + if (change.checkpoints.extra.length) { + lines.push(`checkpoints newly recorded: ${change.checkpoints.extra.join(', ')}`) + } + if (change.checkpoints.reordered) { + lines.push('checkpoints recorded in a different order') + } + for (const difference of change.differences.slice(0, limit)) { + const [first, ...rest] = difference.checkpoints + const also = rest.length + ? ` (and ${rest.length} later checkpoint${rest.length === 1 ? '' : 's'})` + : '' + lines.push(`checkpoint ${first} field ${difference.field}${difference.path}${also}`) + lines.push(` was ${excerpt(difference.expected, width)}`) + lines.push(` now ${excerpt(difference.actual, width)}`) + } + if (change.differences.length > limit) { + lines.push(`… ${change.differences.length - limit} more differences`) + } + return lines +} + +export function formatChangeText(set: GoldenChangeSet, limit = 5): string { + if (changeSetIsEmpty(set)) { + return `No recorded behaviour moved against ${set.base}.\n` + } + const lines = [`RPC recordings against ${set.base}: ${headline(set)}`] + for (const change of set.changed) { + lines.push('', `changed ${change.id} [${change.family}]`) + lines.push(...describeGoldenChange(change, limit).map((line) => ` ${line}`)) + } + for (const [label, items] of [ + ['added', set.added], + ['removed', set.removed] + ] as const) { + if (items.length) { + lines.push('', `${label}:`, ...items.map((item) => ` ${item.id} [${item.family}]`)) + } + } + return `${lines.join('\n')}\n` +} + +/** + * GitHub caps one step summary at 1 MiB, and a recorder change can move ~700 goldens. Families are + * listed in full; details stop at `maxBytes`, so the tail of a large change is names only. + */ +export function formatChangeMarkdown(set: GoldenChangeSet, maxBytes = 200_000): string { + if (changeSetIsEmpty(set)) { + return '' + } + const out: string[] = [ + '## RPC recording changes', + '', + `Against \`${set.base.slice(0, 12)}\`: ${headline(set)}. Reproduce with \`pnpm --dir mobile rpc:diff ${set.base.slice(0, 12)}\`.`, + '' + ] + let size = out.join('\n').length + let detailed = true + const families = new Map() + for (const change of set.changed) { + families.set(change.family, [...(families.get(change.family) ?? []), change]) + } + for (const [family, changes] of families) { + out.push(`
${family}: ${changes.length} changed`, '') + for (const change of changes) { + const body = detailed ? describeGoldenChange(change, 3, 300) : [] + const block = [`**${change.id}**`, ...(body.length ? ['```', ...body, '```'] : []), ''] + const blockSize = block.join('\n').length + if (detailed && size + blockSize > maxBytes) { + detailed = false + out.push('_Details stop here to fit the summary; the rest are listed by name._', '') + } + const shown = detailed ? block : [`- ${change.id}`] + out.push(...shown) + size += shown.join('\n').length + } + out.push('', '') + } + for (const [label, items] of [ + ['Added goldens', set.added], + ['Removed goldens', set.removed] + ] as const) { + if (items.length) { + out.push( + `### ${label} (${items.length})`, + '', + ...items.map((item) => `- ${item.id} (\`${item.family}\`)`), + '' + ) + } + } + return `${out.join('\n')}\n` +} diff --git a/mobile/scripts/rpc-diff.mts b/mobile/scripts/rpc-diff.mts new file mode 100644 index 00000000000..8e4f2463941 --- /dev/null +++ b/mobile/scripts/rpc-diff.mts @@ -0,0 +1,107 @@ +/** + * Decodes the RPC recording goldens at a base commit and in the working tree, and prints which + * recorded behaviour moved: per golden, the checkpoint, the field and the JSON path, with both + * values. The committed files are content-addressed JSON, so their raw diff is hashes. + * + * pnpm --dir mobile rpc:diff [] [--summary ] + * + * `` defaults to the merge base with origin/main. `--summary` also appends a capped Markdown + * report to that file (CI passes `$GITHUB_STEP_SUMMARY`). A behaviour change exits 0: this reports, + * the recording suites judge. It exits non-zero only when git or a golden cannot be read. + */ +import { appendFileSync, readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { diffGoldenSets, formatChangeMarkdown, formatChangeText } from './rpc-diff-report.ts' +import { + decodeGoldenFile, + type DecodedGolden +} from '../src/test-support/rpc-recording/golden-difference.ts' + +const GOLDENS = 'mobile/rpc-foundation/goldens' +// The corpus is ~16 MB; the default 8 MB capture would clip the batch read. +const MAX_OUTPUT_BYTES = 256 * 1024 * 1024 +const root = resolve(import.meta.dirname, '../..') + +async function git(args: string[], input?: string): Promise { + const result = await runProcess({ + program: 'git', + args, + cwd: root, + input, + maxOutputBytes: MAX_OUTPUT_BYTES, + timeoutMs: 120_000 + }) + if (result.code !== 0 || result.outputTruncated) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim() || 'output clipped'}`) + } + return result.stdout +} + +function goldenId(path: string): string | null { + const match = /([^/]+)\.json$/.exec(path) + return match ? match[1]! : null +} + +/** One `cat-file --batch` for the whole corpus rather than a process per golden. */ +async function readBase(base: string): Promise> { + const paths = (await git(['ls-tree', '-z', '--name-only', base, '--', `${GOLDENS}/`])) + .split('\0') + .filter((path) => path.endsWith('.json')) + const goldens = new Map() + if (!paths.length) { + return goldens + } + const batch = Buffer.from( + await git(['cat-file', '--batch'], paths.map((path) => `${base}:${path}\n`).join('')) + ) + let offset = 0 + for (const path of paths) { + const newline = batch.indexOf(0x0a, offset) + const header = batch.subarray(offset, newline).toString() + const size = Number(header.split(' ')[2]) + if (!Number.isInteger(size)) { + throw new Error(`Unexpected cat-file header for ${path}: ${header}`) + } + const body = batch.subarray(newline + 1, newline + 1 + size).toString() + offset = newline + 1 + size + 1 + const id = goldenId(path)! + goldens.set(id, decodeGoldenFile(JSON.parse(body), id)) + } + return goldens +} + +function readWorkingTree(): Map { + const directory = resolve(root, GOLDENS) + const goldens = new Map() + for (const file of readdirSync(directory) + .filter((name) => name.endsWith('.json')) + .sort()) { + const id = goldenId(file)! + goldens.set( + id, + decodeGoldenFile(JSON.parse(readFileSync(resolve(directory, file), 'utf8')), id) + ) + } + return goldens +} + +const argv = process.argv.slice(2) +const summaryAt = argv.indexOf('--summary') +const summary = summaryAt === -1 ? undefined : argv[summaryAt + 1] +if (summaryAt !== -1 && !summary) { + throw new Error('--summary needs a file') +} +const positional = argv.filter( + (_arg, index) => summaryAt === -1 || (index !== summaryAt && index !== summaryAt + 1) +) +if (positional.length > 1 || positional.some((arg) => arg.startsWith('-'))) { + throw new Error('Usage: rpc-diff.mts [] [--summary ]') +} +const base = positional[0] ?? (await git(['merge-base', 'origin/main', 'HEAD'])).trim() +const resolved = (await git(['rev-parse', '--verify', `${base}^{commit}`])).trim() +const set = diffGoldenSets(resolved, await readBase(resolved), readWorkingTree()) +process.stdout.write(formatChangeText(set)) +if (summary) { + appendFileSync(summary, formatChangeMarkdown(set)) +} diff --git a/mobile/scripts/rpc-recording-pin-guard.mts b/mobile/scripts/rpc-recording-pin-guard.mts deleted file mode 100644 index 807893dcb28..00000000000 --- a/mobile/scripts/rpc-recording-pin-guard.mts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Guards the two claims `mobile/rpc-foundation/goldens` makes about its pin. - * - * `reachable` — `baseline` names a commit this repository keeps: one in this history, or one in - * the head of a pull request GitHub associates with that commit. A behaviour-change - * branch pins its own last fenced commit, which the squash leaves out of main's - * history; `refs/pull//head` outlives the branch, so the pin stays checkable out. - * GitHub only names the candidates; git's ancestry check decides. Ordinary product - * drift past a kept pin is normal and is not a failure. - * `reproduce` — the goldens on disk are what the recorder produces from the PINNED tree. The - * recording suites replay the corpus against the CURRENT tree on every run, which is - * the same claim only while the fenced tree still matches the pin. - */ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { cp, mkdtemp, rm, symlink } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { runProcess } from '../../src/shared/child-process/run-process.ts' -import { RECORDING_DRIVERS } from '../src/test-support/rpc-recording/recording-drivers.ts' -import { readScenarios } from '../src/test-support/rpc-recording/scenario-input.ts' -import { checkPinReachable, PIN_MANIFEST } from './rpc-recording-pin-reachability.mts' - -/** The recorder is exempt from the fence, so a reproduction lays the candidate copy over the pin. */ -const RECORDER_OVERLAY = 'mobile/src/test-support/rpc-recording' -/** - * Everything whose change can move the reproduction's verdict: the corpus it compares against, the - * manifest that names the pin and derives the scenarios, the recorder it lays over the pinned - * sources, and this guard, which drives the run. A revision that moves none of these cannot move - * the verdict, which is what lets a pull request skip the run. - */ -const CORPUS_PROVENANCE_PATHS = [ - 'mobile/rpc-foundation', - RECORDER_OVERLAY, - 'mobile/scripts/rpc-recording-pin-guard.mts' -] as const -/** - * The corpus readers that are not drivers. Boundary: a suite belongs here when its verdict is a - * function of the corpus bytes themselves. The `mutants/` suites read the same directory but assert - * that the corpus DETECTS a planted mutation, which is a different claim than reproducing it. - * `derived-goldens` is the census a whole golden spliced in by a merge trips, which no per-golden - * compare can see. - */ -const CORPUS_CENSUS_SUITES = [ - 'derived-goldens.test.ts', - 'golden-recorder-failure-absence.test.ts' -] as const -/** Every suite the reproduction runs against the pinned tree. */ -export const REPRODUCTION_SUITES = [...RECORDING_DRIVERS, ...CORPUS_CENSUS_SUITES] as const -const RECORDING_TIMEOUT_MS = 900_000 -// Windows needs an explicit type for a directory link and a junction needs no privilege, where a -// real symlink does; POSIX ignores the argument. Same rule as src/main/ipc/worktree-symlinks.ts. -const DIRECTORY_LINK = process.platform === 'win32' ? 'junction' : 'dir' -// A failing reproduction prints one diff per golden; 8 MB clips that mid-report. -const RECORDING_OUTPUT_BYTES = 64 * 1024 * 1024 - -/** - * These names reach vitest as positional filename filters, and vitest exits 0 when only some of - * them match. A renamed suite would drop out of the run silently and still report a reproduction, - * so resolve every one of them first. - */ -export function assertReproductionSuitesExist(root: string): void { - for (const suite of REPRODUCTION_SUITES) { - if (!existsSync(resolve(root, RECORDER_OVERLAY, suite))) { - throw new Error( - `${suite} is not in ${RECORDER_OVERLAY}. This list names the suites the reproduction runs ` + - 'and has drifted from the files, which vitest would pass over without a word.' - ) - } - } -} - -async function git(cwd: string, args: readonly string[]) { - return await runProcess({ program: 'git', args: [...args], cwd }) -} -function readPinnedBaseline(root: string): string { - return readScenarios(resolve(root, PIN_MANIFEST)).baseline -} - -/** Whether anything a reproduction reads from the candidate tree moved since `since`. */ -export async function corpusProvenanceChanged(root: string, since: string): Promise { - // Fail closed on a rename: `git diff --quiet` reports "nothing changed" for a pathspec that - // matches no file, which would skip the reproduction forever and report success. - for (const path of CORPUS_PROVENANCE_PATHS) { - const tracked = await git(root, ['ls-files', '--error-unmatch', '--', path]) - if (tracked.code !== 0) { - throw new Error( - `${path} is not a tracked path. This gate decides whether to reproduce the corpus by ` + - 'diffing it, so a rename has to move this list with it.' - ) - } - } - // The branch point, not the base tip: a base that moved on without this branch would otherwise - // read as this branch's change. This is for local invocations, which pass a branch tip. Under CI - // `HEAD` is the merge preview whose first parent is the base, so it resolves to `since` itself. - const branchPoint = await git(root, ['merge-base', since, 'HEAD']) - const from = branchPoint.code === 0 ? branchPoint.stdout.trim() : since - // `git diff` sees tracked paths only, but the overlay copy and the census both read these - // directories as they sit on disk, so an untracked golden or manifest is input to the verdict. - // Run rather than skip: an unjudged local addition is the case the reproduction exists for. - const untracked = await git(root, [ - 'ls-files', - '--others', - '--exclude-standard', - '--', - ...CORPUS_PROVENANCE_PATHS - ]) - if (untracked.code !== 0) { - throw new Error(`Could not enumerate untracked corpus files: ${untracked.stderr.trim()}`) - } - if (untracked.stdout.trim() !== '') { - return true - } - const diff = await git(root, ['diff', '--quiet', from, '--', ...CORPUS_PROVENANCE_PATHS]) - if (diff.code !== 0 && diff.code !== 1) { - throw new Error(`Could not diff the corpus against ${from}: ${diff.stderr.trim()}`) - } - return diff.code === 1 -} - -/** - * Replay the corpus against the pinned tree instead of the current one: check the pin out detached, - * lay this tree's recorder and manifest over it (both exempt from the fence, and both are what the - * goldens pin by digest rather than by commit), and let the recording suites compare in place. The - * comparison is `compareGolden`, so lockfile and platform stay masked the way they are on every - * other run. - */ -async function reproduceFromPin(root: string, baseline: string): Promise { - assertReproductionSuitesExist(root) - const scratch = await mkdtemp(join(tmpdir(), 'rpc-recording-pin-')) - const tree = join(scratch, 'tree') - try { - const added = await git(root, ['worktree', 'add', '--detach', tree, baseline]) - if (added.code !== 0) { - throw new Error(`Could not check out the pinned tree ${baseline}: ${added.stderr.trim()}`) - } - await symlink(resolve(root, 'node_modules'), join(tree, 'node_modules'), DIRECTORY_LINK) - await symlink( - resolve(root, 'mobile/node_modules'), - join(tree, 'mobile/node_modules'), - DIRECTORY_LINK - ) - await rm(join(tree, RECORDER_OVERLAY), { recursive: true, force: true }) - await cp(resolve(root, RECORDER_OVERLAY), join(tree, RECORDER_OVERLAY), { recursive: true }) - const require = createRequire(resolve(root, 'mobile/package.json')) - const result = await runProcess({ - program: process.execPath, - args: [ - resolve(require.resolve('vitest/package.json'), '../vitest.mjs'), - 'run', - ...REPRODUCTION_SUITES.map((suite) => `src/test-support/rpc-recording/${suite}`) - ], - cwd: join(tree, 'mobile'), - timeoutMs: RECORDING_TIMEOUT_MS, - maxOutputBytes: RECORDING_OUTPUT_BYTES, - env: { - ...process.env, - ORCA_BACKGROUND_LAUNCH: '1', - // Replay, never `--record`: the suites read these two from the candidate tree and compare. - RPC_FOUNDATION_GOLDENS: resolve(root, 'mobile/rpc-foundation/goldens'), - RPC_FOUNDATION_SCENARIOS: resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json') - } - }) - process.stdout.write(result.stdout) - process.stderr.write(result.stderr) - if (result.outputTruncated) { - process.stderr.write('\nReproduction output was clipped; the report above is incomplete.\n') - } - if (result.timedOut) { - throw new Error( - `Reproducing the corpus did not finish within ${RECORDING_TIMEOUT_MS / 1000}s and was killed.` - ) - } - return result.code === 0 - } finally { - await removeScratchWorktree(root, tree) - await rm(scratch, { recursive: true, force: true }) - } -} - -/** - * Deregisters the scratch checkout and nothing else. Never `git worktree prune`: that is - * repository-wide, and this git directory is shared by every worktree on the machine, so a prune - * deregisters any of them whose directory is momentarily missing. - */ -export async function removeScratchWorktree(root: string, tree: string): Promise { - const removed = await git(root, ['worktree', 'remove', '--force', tree]) - if (removed.code !== 0) { - process.stderr.write( - `Could not deregister the scratch worktree ${tree}: ${removed.stderr.trim()}\n` + - 'It stays registered against this repository until you prune it yourself.\n' - ) - } -} - -const REPRODUCTION_FAILURE = [ - 'The goldens on disk are not what the recorder produces from the pinned tree.', - '', - 'Each divergence above is a golden whose header names a tree that does not produce it. A merge', - 'that auto-merged golden JSON, or a refresh recorded somewhere other than the pin, both land', - 'here. Re-record the whole corpus from the pin rather than editing a golden:', - '', - ' ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 \\', - ' pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record' -].join('\n') - -async function main(argv: readonly string[]): Promise { - const check = argv[0] - const root = resolve(import.meta.dirname, '../..') - const baseline = readPinnedBaseline(root) - if (check === 'reachable') { - const ref = argv.includes('--ref') ? argv[argv.indexOf('--ref') + 1] : 'HEAD' - if (!ref) { - throw new Error('--ref needs a commit') - } - const verdict = await checkPinReachable(root, baseline, ref) - if (!verdict.ok) { - process.stderr.write(`${verdict.message}\n`) - process.exitCode = 1 - return - } - process.stdout.write( - verdict.pullRequest === null - ? `Recording pin ${baseline} is an ancestor of ${ref}.\n` - : `Recording pin ${baseline} is kept by refs/pull/${verdict.pullRequest}/head.\n` - ) - return - } - if (check === 'reproduce') { - const since = argv.includes('--if-changed-since') - ? argv[argv.indexOf('--if-changed-since') + 1] - : undefined - if (argv.includes('--if-changed-since')) { - if (!since) { - throw new Error('--if-changed-since needs a commit') - } - if (!(await corpusProvenanceChanged(root, since))) { - process.stdout.write( - `Nothing this run reads from the working tree moved since ${since}: not the corpus, not\nthe manifest, not the recorder. The verdict is the one that commit already carries.\n` - ) - return - } - } - // Also fetches a pin that only its pull request's head still holds, so the checkout can find it. - const verdict = await checkPinReachable(root, baseline, 'HEAD') - if (!verdict.ok) { - process.stderr.write(`${verdict.message}\n`) - process.exitCode = 1 - return - } - if (!(await reproduceFromPin(root, baseline))) { - process.stderr.write(`\n${REPRODUCTION_FAILURE}\n`) - process.exitCode = 1 - return - } - process.stdout.write(`\nThe corpus reproduces from the pinned tree ${baseline}.\n`) - return - } - throw new Error( - 'Usage: rpc-recording-pin-guard.mts reachable [--ref ]\n | reproduce [--if-changed-since ]' - ) -} - -// Importing this module for its verdicts must not run a check; the unit test beside it does that. -if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - await main(process.argv.slice(2)) -} diff --git a/mobile/scripts/rpc-recording-pin-guard.test.ts b/mobile/scripts/rpc-recording-pin-guard.test.ts deleted file mode 100644 index 616034d72b1..00000000000 --- a/mobile/scripts/rpc-recording-pin-guard.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../src/shared/child-process/run-process' -import { - assertReproductionSuitesExist, - corpusProvenanceChanged, - removeScratchWorktree, - REPRODUCTION_SUITES -} from './rpc-recording-pin-guard.mts' -import { - checkPinReachable, - gitHubRepositoryFromRemote, - PIN_MANIFEST, - pullRequestsWithCommit, - repinInstruction -} from './rpc-recording-pin-reachability.mts' - -const scratch: string[] = [] -async function git(cwd: string, ...args: string[]): Promise { - const result = await runProcess({ program: 'git', args, cwd }) - if (result.code !== 0) { - throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${result.stderr}`) - } - return result.stdout.trim() -} -/** A repository of our own, so no verdict in this file can depend on — or touch — the real refs. */ -async function throwawayRepository(): Promise { - const directory = await mkdtemp(join(tmpdir(), 'pin-guard-')) - scratch.push(directory) - const repository = join(directory, 'repo') - await git(directory, 'init', '--quiet', 'repo') - // `--initial-branch=main` needs git >= 2.28; symbolic-ref before the first commit works on any. - await git(repository, 'symbolic-ref', 'HEAD', 'refs/heads/main') - await git(repository, 'config', 'user.email', 'pin-guard@example.invalid') - await git(repository, 'config', 'user.name', 'Pin Guard') - return repository -} -async function commit(repository: string, body: string): Promise { - await writeFile(join(repository, 'product.ts'), `${body}\n`) - await git(repository, 'add', 'product.ts') - await git(repository, 'commit', '--quiet', '--no-verify', '--message', body) - return await git(repository, 'rev-parse', 'HEAD') -} -async function commitAt(repository: string, path: string, body: string): Promise { - await mkdir(dirname(join(repository, path)), { recursive: true }) - await writeFile(join(repository, path), `${body}\n`) - await git(repository, 'add', path) - await git(repository, 'commit', '--quiet', '--no-verify', '--message', path) - return await git(repository, 'rev-parse', 'HEAD') -} -const RECORDER_FILE = 'mobile/src/test-support/rpc-recording/run-recording.ts' -const GUARD_FILE = 'mobile/scripts/rpc-recording-pin-guard.mts' -/** Every provenance path: the gate refuses to answer when any one of them names nothing. */ -async function seedProvenance(repository: string): Promise { - await commitAt(repository, 'mobile/rpc-foundation/pilot-scenarios.json', 'pin') - await commitAt(repository, GUARD_FILE, 'guard') - return await commitAt(repository, RECORDER_FILE, 'recorder') -} -const MISSING_SHA = '0123456789abcdef0123456789abcdef01234567' -function pinManifest(baseline: string): string { - return JSON.stringify({ baseline, scenarios: [] }) -} -/** - * A behaviour-change branch that pinned its own commit, squash-merged onto main, with the host - * keeping the branch head at `refs/pull/7/head` the way GitHub does after the branch is gone. - */ -async function squashMergedPin(change: string, subject = `${change} (#7)`) { - const work = await throwawayRepository() - await commit(work, 'one') - await git(work, 'switch', '--quiet', '--create', 'behaviour-change') - const branchPin = await commit(work, change) - await commitAt(work, PIN_MANIFEST, pinManifest(branchPin)) - await git(work, 'switch', '--quiet', 'main') - await git(work, 'merge', '--quiet', '--squash', 'behaviour-change') - await git(work, 'commit', '--quiet', '--no-verify', '--message', subject) - const remote = join(work, '..', 'remote.git') - await git(work, 'init', '--quiet', '--bare', remote) - await git(work, 'push', '--quiet', remote, 'main', 'behaviour-change:refs/pull/7/head') - await git(work, 'branch', '--quiet', '-D', 'behaviour-change') - return { work, remote, branchPin } -} -async function cloneMain(remote: string): Promise { - const clone = join(remote, '..', `clone-${scratch.length}-${Date.now()}`) - await git( - join(remote, '..'), - 'clone', - '--quiet', - '--single-branch', - '--branch', - 'main', - remote, - clone - ) - await git(clone, 'config', 'user.email', 'pin-guard@example.invalid') - await git(clone, 'config', 'user.name', 'Pin Guard') - return clone -} -// Stand-ins for GitHub's "pull requests associated with this commit" answer. -const heldByPullRequest7 = async () => [7] -const heldByNoPullRequest = async () => [] -const unasked = async (): Promise => { - throw new Error('a pin in this history must not send the guard to GitHub') -} - -// File scope, not per-suite: a suite-local hook fires before the later suites have made theirs. -afterAll(async () => { - for (const directory of scratch) { - await rm(directory, { recursive: true, force: true }) - } -}) - -describe('recording pin reachability', () => { - it('passes when the pin is the commit itself', async () => { - const repository = await throwawayRepository() - const first = await commit(repository, 'one') - expect(await checkPinReachable(repository, first, first, unasked)).toMatchObject({ ok: true }) - }) - - it('passes on ordinary drift: the tree has moved on, the pin is still reachable', async () => { - const repository = await throwawayRepository() - const pin = await commit(repository, 'one') - const head = await commit(repository, 'two') - expect(await checkPinReachable(repository, pin, head, unasked)).toMatchObject({ ok: true }) - }) - - it('passes on a branch that pinned its own commit, judged against that branch', async () => { - const repository = await throwawayRepository() - await commit(repository, 'one') - await git(repository, 'switch', '--quiet', '--create', 'behaviour-change') - const branchPin = await commit(repository, 'two') - const branchHead = await commit(repository, 'three') - expect(await checkPinReachable(repository, branchPin, branchHead, unasked)).toMatchObject({ - ok: true - }) - }) - - it('passes a branch cut before main repinned, judged against the merge preview', async () => { - const repository = await throwawayRepository() - const branchPoint = await commitAt(repository, 'mobile/src/session/route.ts', 'base') - const mainPin = await commitAt( - repository, - 'mobile/rpc-foundation/pilot-scenarios.json', - 'repin' - ) - await git(repository, 'switch', '--quiet', '--create', 'refactor', branchPoint) - const branchHead = await commitAt(repository, 'mobile/src/session/route.ts', 'migrated') - await git(repository, 'merge', '--quiet', '--no-edit', 'main') - const preview = await git(repository, 'rev-parse', 'HEAD') - // The preview is the tree CI checks out and reads the pin from, so it is the tree to judge. - expect(await checkPinReachable(repository, mainPin, preview, unasked)).toMatchObject({ - ok: true - }) - // The head sha would have failed this ordinary branch, and told the author to repin to it. - expect( - await checkPinReachable(repository, mainPin, branchHead, heldByNoPullRequest) - ).toMatchObject({ - ok: false, - failure: 'not-an-ancestor' - }) - }) - - it('passes once that branch squash-merges, through the head its pull request keeps', async () => { - const { remote, branchPin } = await squashMergedPin('two') - // Only main: the branch is deleted, so the pin is nowhere in this clone until it is fetched. - const clone = await cloneMain(remote) - expect(await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7)).toMatchObject({ - ok: true, - pullRequest: 7 - }) - const drifted = await commit(clone, 'later product change on main') - expect(await checkPinReachable(clone, branchPin, drifted, heldByPullRequest7)).toMatchObject({ - ok: true, - pullRequest: 7 - }) - // The reproduction checks the pinned tree out next, which needs the fetched commit. - await git(clone, 'cat-file', '-e', `${branchPin}^{commit}`) - }) - - it('passes a branch opened after that squash, judged against its merge preview', async () => { - const { remote, branchPin } = await squashMergedPin('two') - const clone = await cloneMain(remote) - await git(clone, 'switch', '--quiet', '--create', 'later-branch') - await commitAt(clone, 'mobile/src/later.ts', 'later branch change') - await git(clone, 'switch', '--quiet', '--detach', 'main') - await commit(clone, 'main moved on') - await git(clone, 'merge', '--quiet', '--no-ff', '--no-edit', 'later-branch') - expect(await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7)).toMatchObject({ - ok: true, - pullRequest: 7 - }) - }) - - it('passes a squash whose edited title lost its (#n), since GitHub names the pull request', async () => { - const { remote, branchPin } = await squashMergedPin('two', 'two, title edited at merge') - const clone = await cloneMain(remote) - expect(await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7)).toMatchObject({ - ok: true, - pullRequest: 7 - }) - }) - - it('passes through the first associated pull request whose head holds the pin', async () => { - const { remote, branchPin, work } = await squashMergedPin('two') - await git(work, 'push', '--quiet', remote, 'main:refs/pull/9/head') - const clone = await cloneMain(remote) - expect(await checkPinReachable(clone, branchPin, 'HEAD', async () => [9, 7])).toMatchObject({ - ok: true, - pullRequest: 7 - }) - }) - - it('fails the merge preview of a branch whose rebase dropped the commit it pinned', async () => { - const { remote } = await squashMergedPin('two') - const clone = await cloneMain(remote) - await git(clone, 'switch', '--quiet', '--create', 'rebased') - const droppedPin = await commit(clone, 'three') - await commitAt(clone, PIN_MANIFEST, pinManifest(droppedPin)) - await git(clone, 'switch', '--quiet', 'main') - await commitAt(clone, 'mobile/src/moved.ts', 'main moved on') - // The rebase rewrites both commits; the manifest still names the one that is gone. - await git(clone, 'rebase', '--quiet', 'main', 'rebased') - await git(clone, 'switch', '--quiet', '--detach', 'main') - await git(clone, 'merge', '--quiet', '--no-ff', '--no-edit', 'rebased') - // Caught on the pull request: after the squash no head would hold the pin either. - const verdict = await checkPinReachable(clone, droppedPin, 'HEAD', heldByNoPullRequest) - expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) - expect(verdict.ok ? '' : verdict.message).toContain('GitHub associates no pull request') - }) - - it("fails when the pull request's head no longer holds the pin", async () => { - const { remote, branchPin, work } = await squashMergedPin('two') - // A force-push after pinning: the pin is left in no ref the host keeps. - await git(work, 'push', '--quiet', '--force', remote, 'main:refs/pull/7/head') - await git(work, 'push', '--quiet', remote, `${branchPin}:refs/heads/stray`) - const clone = await cloneMain(remote) - await git(clone, 'fetch', '--quiet', 'origin', 'stray') - const verdict = await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7) - expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) - expect(verdict.ok ? '' : verdict.message).toContain('refs/pull/7/head') - }) - - it('fails once that branch squash-merges if GitHub associates no pull request with it', async () => { - const repository = await throwawayRepository() - const base = await commit(repository, 'one') - await git(repository, 'switch', '--quiet', '--create', 'behaviour-change') - const branchPin = await commit(repository, 'two') - await git(repository, 'switch', '--quiet', 'main') - await git(repository, 'reset', '--quiet', '--hard', base) - await commitAt(repository, PIN_MANIFEST, pinManifest(branchPin)) - const squashed = await commit(repository, 'two, squashed') - const verdict = await checkPinReachable(repository, branchPin, squashed, heldByNoPullRequest) - expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) - expect(verdict.ok).toBe(false) - if (verdict.ok) { - return - } - expect(verdict.message).toContain(branchPin) - expect(verdict.message).toContain('GitHub associates no pull request') - expect(verdict.message).toContain('scripts/rpc-recording.mts --record') - expect(verdict.message).toContain('mobile/rpc-foundation/pilot-scenarios.json') - }) - - it('fails with the same instruction when the pin is no commit at all', async () => { - const repository = await throwawayRepository() - const head = await commit(repository, 'one') - const verdict = await checkPinReachable(repository, MISSING_SHA, head, heldByNoPullRequest) - expect(verdict).toMatchObject({ ok: false, failure: 'unreachable' }) - expect(verdict.ok ? '' : verdict.message).toContain('scripts/rpc-recording.mts --record') - }) - - it('lets a failed GitHub lookup propagate, never a pass or a repin instruction', async () => { - const { remote, branchPin } = await squashMergedPin('two') - const clone = await cloneMain(remote) - const broken = async (): Promise => { - throw new Error('GitHub answered 502') - } - await expect(checkPinReachable(clone, branchPin, 'HEAD', broken)).rejects.toThrow( - 'GitHub answered 502' - ) - }) - - it('refuses to answer on a shallow clone instead of trusting grafted history', async () => { - const repository = await throwawayRepository() - const pin = await commit(repository, 'one') - await commit(repository, 'two') - const head = await commit(repository, 'three') - const clone = join(repository, '..', 'shallow') - await git(repository, 'clone', '--quiet', '--depth', '1', `file://${repository}`, clone) - // The pin is real and reachable in the full repository; only the missing history hides it. - expect(await checkPinReachable(repository, pin, head, unasked)).toMatchObject({ ok: true }) - const verdict = await checkPinReachable(clone, pin, 'HEAD', unasked) - expect(verdict).toMatchObject({ ok: false, failure: 'shallow' }) - expect(verdict.ok ? '' : verdict.message).toContain('fetch-depth: 0') - }) - - it('names the head and the pin in the instruction', () => { - expect( - repinInstruction('a'.repeat(40), 'b'.repeat(40), 'is not a commit in this repository at all') - ).toContain(`git switch -c repin-rpc-recording ${'b'.repeat(40)}`) - }) -}) - -describe("GitHub's pull requests for a pinned commit", () => { - const pin = 'a'.repeat(40) - const answer = (status: number, body: unknown) => - vi.fn(async () => new Response(JSON.stringify(body), { status })) - afterEach(() => { - vi.unstubAllGlobals() - vi.unstubAllEnvs() - }) - - it('asks with the token when one is set and reads the pull request numbers', async () => { - vi.stubEnv('GITHUB_REPOSITORY', 'stablyai/orca') - vi.stubEnv('GITHUB_TOKEN', 'test-token') - const fetch = answer(200, [{ number: 22762 }, { number: 5 }]) - vi.stubGlobal('fetch', fetch) - expect(await pullRequestsWithCommit('/unused', pin)).toEqual([22762, 5]) - expect(fetch).toHaveBeenCalledWith( - `https://api.github.com/repos/stablyai/orca/commits/${pin}/pulls`, - expect.objectContaining({ - headers: { - Accept: 'application/vnd.github+json', - Authorization: 'Bearer test-token' - } - }) - ) - }) - - it('reads no pull request for a commit GitHub has never seen', async () => { - vi.stubEnv('GITHUB_REPOSITORY', 'stablyai/orca') - vi.stubGlobal('fetch', answer(422, { message: `No commit found for SHA: ${pin}` })) - expect(await pullRequestsWithCommit('/unused', pin)).toEqual([]) - }) - - it('names the rate limit when GitHub refuses a call made without a token', async () => { - vi.stubEnv('GITHUB_REPOSITORY', 'stablyai/orca') - vi.stubEnv('GITHUB_TOKEN', '') - vi.stubEnv('GH_TOKEN', '') - vi.stubGlobal('fetch', answer(403, { message: 'API rate limit exceeded' })) - await expect(pullRequestsWithCommit('/unused', pin)).rejects.toThrow( - /answered 403[\s\S]*API rate limit exceeded[\s\S]*set GITHUB_TOKEN or GH_TOKEN/ - ) - }) - - it('reads owner and repo from https and ssh remotes, and nothing from another host', () => { - expect(gitHubRepositoryFromRemote('https://github.com/stablyai/orca.git\n')).toBe( - 'stablyai/orca' - ) - expect(gitHubRepositoryFromRemote('https://github.com/stablyai/orca')).toBe('stablyai/orca') - expect(gitHubRepositoryFromRemote('git@github.com:stablyai/orca.git')).toBe('stablyai/orca') - expect(gitHubRepositoryFromRemote('ssh://git@github.com/stablyai/orca.git')).toBe( - 'stablyai/orca' - ) - expect(gitHubRepositoryFromRemote('/tmp/remote.git')).toBeNull() - }) -}) - -describe('what a reproduction reads from the candidate tree', () => { - it('skips the run when only product sources moved', async () => { - const repository = await throwawayRepository() - await seedProvenance(repository) - const base = await commitAt(repository, 'mobile/src/session/route.ts', 'before') - await commitAt(repository, 'mobile/src/session/route.ts', 'after') - expect(await corpusProvenanceChanged(repository, base)).toBe(false) - }) - - it('runs when a golden moved', async () => { - const repository = await throwawayRepository() - await seedProvenance(repository) - const base = await commitAt(repository, 'mobile/rpc-foundation/goldens/a.json', '{}') - await commitAt(repository, 'mobile/rpc-foundation/goldens/a.json', '{"spliced": true}') - expect(await corpusProvenanceChanged(repository, base)).toBe(true) - }) - - it('runs when the pin itself moved', async () => { - const repository = await throwawayRepository() - await seedProvenance(repository) - const base = await commitAt(repository, 'mobile/rpc-foundation/pilot-scenarios.json', 'one') - await commitAt(repository, 'mobile/rpc-foundation/pilot-scenarios.json', 'two') - expect(await corpusProvenanceChanged(repository, base)).toBe(true) - }) - - it('ignores a corpus change the base branch made without this branch', async () => { - const repository = await throwawayRepository() - await seedProvenance(repository) - const branchPoint = await commitAt(repository, 'mobile/rpc-foundation/goldens/a.json', '{}') - await commitAt(repository, 'mobile/rpc-foundation/goldens/b.json', '{}') - const baseTip = await git(repository, 'rev-parse', 'HEAD') - await git(repository, 'switch', '--quiet', '--create', 'refactor', branchPoint) - await commitAt(repository, 'mobile/src/session/route.ts', 'migrated') - expect(await corpusProvenanceChanged(repository, baseTip)).toBe(false) - }) - - it('runs when the recorder moved, because every golden pins it by digest', async () => { - const repository = await throwawayRepository() - const base = await seedProvenance(repository) - await commitAt(repository, RECORDER_FILE, 'two') - expect(await corpusProvenanceChanged(repository, base)).toBe(true) - }) - - it('runs when the guard itself moved, because it decides the skip and drives the run', async () => { - const repository = await throwawayRepository() - const base = await seedProvenance(repository) - await commitAt(repository, GUARD_FILE, 'changed') - expect(await corpusProvenanceChanged(repository, base)).toBe(true) - }) - - it('runs on an untracked golden, which no diff of tracked paths can see', async () => { - const repository = await throwawayRepository() - const base = await seedProvenance(repository) - const golden = join(repository, 'mobile/rpc-foundation/goldens/local.json') - await mkdir(dirname(golden), { recursive: true }) - await writeFile(golden, '{}\n') - // The overlay copy and the census both read the directory as it sits on disk. - expect(await corpusProvenanceChanged(repository, base)).toBe(true) - }) - - it('refuses to answer when a provenance path names nothing, instead of skipping forever', async () => { - const repository = await throwawayRepository() - const base = await seedProvenance(repository) - await git(repository, 'mv', 'mobile/rpc-foundation', 'mobile/rpc-corpus') - await git(repository, 'commit', '--quiet', '--no-verify', '--message', 'rename the corpus') - await expect(corpusProvenanceChanged(repository, base)).rejects.toThrow('not a tracked path') - }) -}) - -describe('scratch worktree teardown', () => { - it('leaves an unrelated worktree registered when its directory is missing', async () => { - const repository = await throwawayRepository() - await commit(repository, 'one') - const trees = join(repository, '..', 'trees') - for (const name of ['scratch', 'kept', 'unmounted']) { - await git(repository, 'worktree', 'add', '--quiet', '--detach', join(trees, name)) - } - // Stands in for a worktree on an unmounted volume: `git worktree prune` would deregister it. - await rm(join(trees, 'unmounted'), { recursive: true, force: true }) - await removeScratchWorktree(repository, join(trees, 'scratch')) - const registered = await git(repository, 'worktree', 'list', '--porcelain') - expect(registered).toContain('trees/kept') - expect(registered).toContain('trees/unmounted') - expect(registered).not.toContain('trees/scratch') - }) -}) - -describe('the suites a reproduction runs', () => { - const overlay = 'mobile/src/test-support/rpc-recording' - - it('every name resolves to a file in this repository', () => { - expect(() => assertReproductionSuitesExist(resolve(import.meta.dirname, '../..'))).not.toThrow() - }) - - it('throws for the one that drifted, rather than letting vitest pass over it', async () => { - for (const renamed of REPRODUCTION_SUITES) { - const root = await mkdtemp(join(tmpdir(), 'pin-guard-suites-')) - scratch.push(root) - await mkdir(join(root, overlay), { recursive: true }) - for (const suite of REPRODUCTION_SUITES.filter((name) => name !== renamed)) { - await writeFile(join(root, overlay, suite), '') - } - expect(() => assertReproductionSuitesExist(root)).toThrow(renamed) - } - }) -}) diff --git a/mobile/scripts/rpc-recording-pin-reachability.mts b/mobile/scripts/rpc-recording-pin-reachability.mts deleted file mode 100644 index c7ec31ccfb1..00000000000 --- a/mobile/scripts/rpc-recording-pin-reachability.mts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Whether the RPC recording pin names a commit this repository keeps: one in this history, or one in - * the head of a pull request that GitHub associates with the pinned commit. - */ -import { runProcess } from '../../src/shared/child-process/run-process.ts' - -export const PIN_MANIFEST = 'mobile/rpc-foundation/pilot-scenarios.json' -const PIN_REMOTE = 'origin' -const PIN_FETCH_TIMEOUT_MS = 180_000 -const PULL_REQUEST_LOOKUP_TIMEOUT_MS = 30_000 - -export type PinReachabilityFailure = 'shallow' | 'unreachable' | 'not-an-ancestor' -export type PinReachabilityVerdict = - | { ok: true; baseline: string; ref: string; pullRequest: number | null } - | { ok: false; baseline: string; ref: string; failure: PinReachabilityFailure; message: string } -/** Only nominates pull requests; git's ancestry check decides, so a wrong answer can only fail. */ -export type PullRequestLookup = (root: string, commit: string) => Promise - -async function git(cwd: string, args: readonly string[], timeoutMs?: number) { - return await runProcess({ program: 'git', args: [...args], cwd, timeoutMs }) -} -function failureDetail(result: { stderr: string; timedOut: boolean }, timeoutMs: number): string { - return result.timedOut ? `timed out after ${timeoutMs / 1000}s` : result.stderr.trim() -} -export function repinInstruction(baseline: string, ref: string, cause: string): string { - return [ - `The RPC recording corpus is pinned to a commit that ${cause}.`, - '', - ` baseline ${baseline} (mobile/rpc-foundation/pilot-scenarios.json)`, - ` head ${ref}`, - '', - 'Every golden under mobile/rpc-foundation/goldens claims it was recorded from that tree, and', - '`--record` refuses on any other tree, so the corpus cannot be refreshed until the pin names a', - 'commit that is reachable from here. Repin and re-record, both in one commit:', - '', - ` git switch -c repin-rpc-recording ${ref}`, - ` # set "baseline" in mobile/rpc-foundation/pilot-scenarios.json to ${ref}`, - ' ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 \\', - ' pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record', - '', - 'Re-record everything: the repin rewrites the `baseline` header of every golden, so a partial', - 'refresh leaves the corpus pinned to two different trees. See', - 'mobile/src/test-support/rpc-recording/README.md, "Recording a behaviour change".' - ].join('\n') -} -const SHALLOW_MESSAGE = [ - 'Cannot judge the recording pin: this is a shallow clone.', - '', - '`git merge-base --is-ancestor` answers from grafted history, so it would report a verdict this', - 'guard has no evidence for. Check out with `fetch-depth: 0`.' -].join('\n') - -async function isAncestor(root: string, commit: string, of: string): Promise { - const ancestor = await git(root, ['merge-base', '--is-ancestor', commit, of]) - // Why only 0 and 1: git reserves higher codes for real errors, and treating one as "not an - // ancestor" would turn a broken repository into a repin instruction nobody can act on. - if (ancestor.code !== 0 && ancestor.code !== 1) { - throw new Error(`git merge-base --is-ancestor failed: ${ancestor.stderr.trim()}`) - } - return ancestor.code === 0 -} - -/** `owner/repo` from a GitHub remote URL, https or ssh. */ -export function gitHubRepositoryFromRemote(url: string): string | null { - const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim()) - return match ? `${match[1]}/${match[2]}` : null -} -async function gitHubRepository(root: string): Promise { - if (process.env.GITHUB_REPOSITORY) { - return process.env.GITHUB_REPOSITORY - } - const remote = await git(root, ['remote', 'get-url', PIN_REMOTE]) - const repository = remote.code === 0 ? gitHubRepositoryFromRemote(remote.stdout) : null - if (!repository) { - throw new Error( - `Cannot tell which GitHub repository ${PIN_REMOTE} is ` + - `(${remote.stdout.trim() || remote.stderr.trim()}); set GITHUB_REPOSITORY=/` - ) - } - return repository -} - -/** The pull requests GitHub associates with `commit`, whatever the squash title says. */ -export async function pullRequestsWithCommit(root: string, commit: string): Promise { - const url = `https://api.github.com/repos/${await gitHubRepository(root)}/commits/${commit}/pulls` - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN - const response = await fetch(url, { - headers: { - Accept: 'application/vnd.github+json', - ...(token ? { Authorization: `Bearer ${token}` } : {}) - }, - signal: AbortSignal.timeout(PULL_REQUEST_LOOKUP_TIMEOUT_MS) - }).catch((error: unknown) => { - // Node's fetch hides the network reason (DNS, TLS, reset) in `cause`. - const cause = error instanceof Error && error.cause ? ` (${String(error.cause)})` : '' - throw new Error( - `Could not ask GitHub which pull requests hold ${commit} (${url}): ${String(error)}${cause}` - ) - }) - const body = await response.text() - // GitHub's answer for a commit it has never seen, which no pull request can hold. - if (response.status === 422 && body.includes('No commit found')) { - return [] - } - if (!response.ok) { - const rateLimited = !token && (response.status === 403 || response.status === 429) - throw new Error( - `GitHub answered ${response.status} to ${url}: ${body.slice(0, 300)}` + - (rateLimited - ? '\nUnauthenticated GitHub API calls are rate-limited; set GITHUB_TOKEN or GH_TOKEN.' - : '') - ) - } - const pulls: unknown = JSON.parse(body) - if (!Array.isArray(pulls)) { - throw new Error( - `GitHub answered ${url} with something other than a list: ${body.slice(0, 300)}` - ) - } - return pulls.flatMap((pull) => (typeof pull?.number === 'number' ? [pull.number] : [])) -} - -/** Fetches a pull request's head into a ref of our own, so no other fetch can move it under us. */ -async function fetchPullRequestHead(root: string, pullRequest: number): Promise { - const local = `refs/rpc-recording-pin/pull/${pullRequest}` - const fetched = await git( - root, - ['fetch', '--quiet', '--no-tags', PIN_REMOTE, `+refs/pull/${pullRequest}/head:${local}`], - PIN_FETCH_TIMEOUT_MS - ) - if (fetched.code !== 0) { - const detail = failureDetail(fetched, PIN_FETCH_TIMEOUT_MS) - throw new Error( - `Could not fetch refs/pull/${pullRequest}/head from ${PIN_REMOTE}, the head of a pull ` + - `request GitHub associates with the recording pin: ${detail}` - ) - } - return local -} - -export async function checkPinReachable( - root: string, - baseline: string, - ref: string, - pullRequestsHolding: PullRequestLookup = pullRequestsWithCommit -): Promise { - const shallow = await git(root, ['rev-parse', '--is-shallow-repository']) - if (shallow.code !== 0) { - throw new Error(`Could not ask git whether the clone is shallow: ${shallow.stderr.trim()}`) - } - if (shallow.stdout.trim() !== 'false') { - return { ok: false, baseline, ref, failure: 'shallow', message: SHALLOW_MESSAGE } - } - const head = await git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) - if (head.code !== 0) { - throw new Error(`Cannot resolve ${ref} to a commit in this repository`) - } - // Resolved, because the instruction below is a command to paste: `HEAD` in it moves with whatever - // the reader has checked out by the time they read the log. - const resolved = head.stdout.trim() - const present = async () => - (await git(root, ['rev-parse', '--verify', '--quiet', `${baseline}^{commit}`])).code === 0 - if ((await present()) && (await isAncestor(root, baseline, ref))) { - return { ok: true, baseline, ref, pullRequest: null } - } - // A squash drops the branch commit the pin names, and the branch may already be deleted, but - // GitHub keeps the pull request's head ref for good. - const pullRequests = await pullRequestsHolding(root, baseline) - for (const pullRequest of pullRequests) { - const pullRequestHead = await fetchPullRequestHead(root, pullRequest) - if ((await present()) && (await isAncestor(root, baseline, pullRequestHead))) { - return { ok: true, baseline, ref, pullRequest } - } - } - const pullRequestCause = - pullRequests.length === 0 - ? 'and GitHub associates no pull request with it' - : 'and no head of a pull request GitHub associates with it holds it ' + - `(${pullRequests.map((pullRequest) => `refs/pull/${pullRequest}/head`).join(', ')})` - if (!(await present())) { - return { - ok: false, - baseline, - ref, - failure: 'unreachable', - message: repinInstruction( - baseline, - resolved, - `is not a commit in this clone, ${pullRequestCause}` - ) - } - } - return { - ok: false, - baseline, - ref, - failure: 'not-an-ancestor', - message: repinInstruction( - baseline, - resolved, - `is not an ancestor of this commit, ${pullRequestCause}` - ) - } -} diff --git a/mobile/scripts/rpc-recording.mts b/mobile/scripts/rpc-recording.mts index c6034a9246e..6e4e921184d 100644 --- a/mobile/scripts/rpc-recording.mts +++ b/mobile/scripts/rpc-recording.mts @@ -1,87 +1,90 @@ +/** + * Re-records the RPC recording goldens from the current tree. + * + * pnpm --dir mobile rpc:record [...] [--prune] + * + * With ids, only those goldens are recorded (each derived test title starts with its golden id). + * An unchanged behaviour re-records to identical bytes, so recording everything is always safe and + * `git diff` shows only what moved. `--prune` deletes goldens the manifest no longer derives and + * prints each one; without it they are only listed, and the census test keeps failing on them. + */ import { createRequire } from 'node:module' +import { readdirSync, rmSync } from 'node:fs' import { resolve } from 'node:path' import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { derivedGoldens } from '../src/test-support/rpc-recording/derived-goldens.ts' import { RECORDING_DRIVERS } from '../src/test-support/rpc-recording/recording-drivers.ts' import { readScenarios } from '../src/test-support/rpc-recording/scenario-input.ts' -if (process.argv[2] !== '--record' || process.env.RPC_FOUNDATION_RECORD !== '1') { - throw new Error('Recording requires --record and RPC_FOUNDATION_RECORD=1') -} -const root = resolve(import.meta.dirname, '../..') -const input = readScenarios(resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json')) -const baseline = await runProcess({ - program: 'git', - args: [ - 'diff', - '--quiet', - input.baseline, - '--', - 'mobile/src', - 'src/shared', - 'mobile/pnpm-lock.yaml', - // Only the recorder is exempt, and every golden pins `recorderSha256` over it instead. - ':!mobile/src/test-support/rpc-recording' - ], - cwd: root -}) -// A pin a squash left only in its pull request's head is a missing object, not product drift. -if (baseline.code !== 0 && baseline.code !== 1) { - throw new Error( - `Could not diff against the pinned baseline ${input.baseline}: ${baseline.stderr.trim()}\n` + - 'If your clone lacks that commit, fetch it with\n' + - ' pnpm --dir mobile exec tsx scripts/rpc-recording-pin-guard.mts reachable' - ) -} -if (baseline.code === 1) { - throw new Error('Product sources or lockfile differ from the pinned main baseline') -} -// Why a second check: `git diff` only sees tracked paths, so an untracked module under the -// guarded trees can change resolution while the baseline check still passes — the golden would -// then carry a pinned baseline header it did not actually record against. -const untracked = await runProcess({ - program: 'git', - args: [ - 'ls-files', - '--others', - '--exclude-standard', - '--', - 'mobile/src', - 'src/shared', - ':!mobile/src/test-support/rpc-recording' - ], - cwd: root -}) -if (untracked.code !== 0) { - throw new Error(`Could not enumerate untracked product sources: ${untracked.stderr}`) -} -if (untracked.stdout.trim() !== '') { - throw new Error( - `Untracked product sources would not be pinned by the baseline:\n${untracked.stdout.trim()}` - ) -} -// Ten minutes, not two: the corpus already records in ~110s, so the old 120s budget killed the run -// on any cold cache and reported it as a truncated failure rather than as a timeout. +// Ten minutes, not two: the corpus records in 150-290s, so the old 120s budget killed every run and +// reported it as a truncated failure rather than as a timeout. const RECORDING_TIMEOUT_MS = 600_000 +const root = resolve(import.meta.dirname, '../..') +const goldens = resolve(root, 'mobile/rpc-foundation/goldens') +const argv = process.argv.slice(2) +const prune = argv.includes('--prune') +const ids = argv.filter((arg) => arg !== '--prune') +const unknownFlag = ids.find((arg) => arg.startsWith('-')) +if (unknownFlag) { + throw new Error( + `Unknown option ${unknownFlag}. Usage: rpc-recording.mts [...] [--prune]` + ) +} +const derived = new Set( + derivedGoldens( + readScenarios(resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json')).scenarios + ).map((golden) => golden.id) +) +const unknownIds = ids.filter((id) => !derived.has(id)) +if (unknownIds.length) { + throw new Error(`The manifest derives no golden named ${unknownIds.join(', ')}`) +} + +// Orphans come from the manifest, not the run, so a failed or killed run still lists or prunes them. +function handleOrphans(): void { + const orphans = readdirSync(goldens) + .filter((file) => file.endsWith('.json') && !derived.has(file.replace(/\.json$/, ''))) + .sort() + for (const file of orphans) { + if (prune) { + rmSync(resolve(goldens, file)) + process.stdout.write(`pruned ${file}: the manifest no longer derives it\n`) + } else { + process.stdout.write( + `orphaned ${file}: the manifest no longer derives it (--prune deletes it)\n` + ) + } + } +} + +const escape = (id: string) => id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const require = createRequire(resolve(root, 'mobile/package.json')) -const result = await runProcess({ - program: process.execPath, - args: [ - resolve(require.resolve('vitest/package.json'), '../vitest.mjs'), - 'run', - ...RECORDING_DRIVERS.map((driver) => `src/test-support/rpc-recording/${driver}`) - ], - cwd: resolve(root, 'mobile'), - timeoutMs: RECORDING_TIMEOUT_MS, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', RPC_FOUNDATION_MODE: '--record' } -}) -process.stdout.write(result.stdout) -process.stderr.write(result.stderr) -if (result.timedOut) { - // Why: a killed run writes a partial reporter line and nothing else, which reads as a failing - // test rather than as a run that never finished. - throw new Error(`Recording did not finish within ${RECORDING_TIMEOUT_MS / 1000}s and was killed.`) -} -if (result.code !== 0) { - process.exitCode = 1 +try { + const result = await runProcess({ + program: process.execPath, + args: [ + resolve(require.resolve('vitest/package.json'), '../vitest.mjs'), + 'run', + ...RECORDING_DRIVERS.map((driver) => `src/test-support/rpc-recording/${driver}`), + ...(ids.length ? ['-t', `(?:^| )(?:${ids.map(escape).join('|')}): `] : []) + ], + cwd: resolve(root, 'mobile'), + timeoutMs: RECORDING_TIMEOUT_MS, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', RPC_FOUNDATION_MODE: '--record' }, + // Streamed, not captured: a capture holds a multi-minute run silent and clips its tail past 8 MB. + stdio: 'inherit' + }) + if (result.timedOut) { + // Why: a killed run writes a partial reporter line and nothing else, which reads as a failing + // test rather than as a run that never finished. + throw new Error( + `Recording did not finish within ${RECORDING_TIMEOUT_MS / 1000}s and was killed.` + ) + } + if (result.code !== 0) { + process.exitCode = 1 + } +} finally { + handleOrphans() } diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 0b25d44d3f0..2a77c8a2b3a 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { mobileAccountsRouteTarget } from './mobile-accounts-route' import { @@ -7,12 +6,6 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') -const accountCardsSource = readFileSync( - new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url), - 'utf8' -) - function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() let state = initialState @@ -85,10 +78,4 @@ describe('mobile accounts route', () => { payload: mobileAccountsRouteTarget('host/one') }) }) - - it('opens the home account-usage card through the cold-navigator-safe transition', () => { - expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}') - expect(accountCardsSource).toContain('props.onOpen(host.id)') - expect(accountCardsSource).not.toContain('/accounts`') - }) }) diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts index d59feb98e15..9c62fa7b34b 100644 --- a/mobile/src/agent-history/agent-history-resume-target.test.ts +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - getMobileAiVaultResumeRepoTargetStatus, - getMobileAiVaultResumeWorktreeTargetStatus, - isSupportedMobileAiVaultResumeTargetStatus, - mobileAiVaultResumeTargetBlockMessage, - resolveMobileAiVaultSessionResumeTarget -} from './agent-history-resume-target' +import { resolveMobileAiVaultSessionResumeTarget } from './agent-history-resume-target' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' import type { Worktree } from '../worktree/workspace-list-types' @@ -52,27 +46,6 @@ function worktree(overrides: Partial & { worktreeId: string; path: str } describe('mobile AI Vault resume target guards', () => { - const worktrees = [ - { worktreeId: 'local-wt', repoId: 'local-repo' }, - { worktreeId: 'ssh-wt', repoId: 'ssh-repo' }, - { worktreeId: 'runtime-wt', repoId: 'runtime-repo' }, - { worktreeId: 'host-owned-wt', repoId: 'local-repo', hostId: 'ssh:builder' as const }, - { - worktreeId: 'folder:folder-local', - repoId: 'folder-workspace:group-local', - workspaceKind: 'folder-workspace' as const - }, - { - worktreeId: 'folder:folder-ssh', - repoId: 'folder-workspace:group-local', - workspaceKind: 'folder-workspace' as const - }, - { - worktreeId: 'folder:folder-runtime', - repoId: 'folder-workspace:group-runtime', - workspaceKind: 'folder-workspace' as const - } - ] const repos = [ { id: 'local-repo', path: '/Users/ada/repo', connectionId: null }, { id: 'ssh-repo', path: '/home/ada/ssh-repo', connectionId: 'builder' }, @@ -98,68 +71,6 @@ describe('mobile AI Vault resume target guards', () => { { id: 'group-runtime', executionHostId: 'runtime:devbox' as const } ] - it('classifies local, SSH, runtime, and unknown repos', () => { - expect(getMobileAiVaultResumeRepoTargetStatus(repos[0])).toBe('local') - expect(getMobileAiVaultResumeRepoTargetStatus(repos[1])).toBe('ssh') - expect(getMobileAiVaultResumeRepoTargetStatus(repos[2])).toBe('runtime') - expect(getMobileAiVaultResumeRepoTargetStatus(null)).toBe('unknown') - }) - - it('classifies worktrees from their repo, with worktree host taking precedence', () => { - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'local-wt', worktrees, repos }) - ).toBe('local') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'ssh-wt', worktrees, repos }) - ).toBe('ssh') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'runtime-wt', worktrees, repos }) - ).toBe('runtime') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'host-owned-wt', worktrees, repos }) - ).toBe('ssh') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ - worktreeId: 'folder:folder-local', - worktrees, - repos, - folderWorkspaces, - projectGroups - }) - ).toBe('local') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ - worktreeId: 'folder:folder-ssh', - worktrees, - repos, - folderWorkspaces, - projectGroups - }) - ).toBe('ssh') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ - worktreeId: 'folder:folder-runtime', - worktrees, - repos, - folderWorkspaces, - projectGroups - }) - ).toBe('runtime') - expect( - getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'missing', worktrees, repos }) - ).toBe('unknown') - }) - - it('supports local targets only; SSH hosts cannot see host-local transcripts', () => { - expect(isSupportedMobileAiVaultResumeTargetStatus('local')).toBe(true) - expect(isSupportedMobileAiVaultResumeTargetStatus('ssh')).toBe(false) - expect(isSupportedMobileAiVaultResumeTargetStatus('runtime')).toBe(false) - expect(mobileAiVaultResumeTargetBlockMessage('runtime')).toContain('runtime-hosted') - expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('SSH workspace') - expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('local workspace') - expect(mobileAiVaultResumeTargetBlockMessage('unknown')).toContain('local workspace') - }) - it('resolves project-scope rows to the matched session worktree before the route worktree', () => { const target = resolveMobileAiVaultSessionResumeTarget({ session: session({ cwd: '/Users/ada/repo/feature/src' }), diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts index a63a7ca92c7..d873a9df58f 100644 --- a/mobile/src/agent-history/agent-history-resume-target.ts +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -53,7 +53,7 @@ export type MobileAiVaultSessionResumeTarget = } | { status: 'blocked'; message: string } -export function getMobileAiVaultResumeRepoTargetStatus( +function getMobileAiVaultResumeRepoTargetStatus( repo: MobileAiVaultResumeRepo | null | undefined ): MobileAiVaultResumeTargetStatus { if (!repo) { @@ -62,7 +62,7 @@ export function getMobileAiVaultResumeRepoTargetStatus( return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo)) } -export function getMobileAiVaultResumeWorktreeTargetStatus(args: { +function getMobileAiVaultResumeWorktreeTargetStatus(args: { worktreeId: string | null worktrees: readonly MobileAiVaultResumeWorktree[] repos: readonly MobileAiVaultResumeRepo[] @@ -94,7 +94,7 @@ export function getMobileAiVaultResumeWorktreeTargetStatus(args: { ) } -export function isSupportedMobileAiVaultResumeTargetStatus( +function isSupportedMobileAiVaultResumeTargetStatus( status: MobileAiVaultResumeTargetStatus ): status is 'local' { // Why: mobile sessions come from the host-local transcript scan, so an SSH @@ -102,9 +102,7 @@ export function isSupportedMobileAiVaultResumeTargetStatus( return status === 'local' } -export function mobileAiVaultResumeTargetBlockMessage( - status: MobileAiVaultResumeTargetStatus -): string { +function mobileAiVaultResumeTargetBlockMessage(status: MobileAiVaultResumeTargetStatus): string { if (status === 'runtime') { return 'Resume from history is not available in runtime-hosted workspaces.' } diff --git a/mobile/src/app-update/use-wall-app-update.test.ts b/mobile/src/app-update/use-wall-app-update.test.ts new file mode 100644 index 00000000000..96dcde4fe5d --- /dev/null +++ b/mobile/src/app-update/use-wall-app-update.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AppUpdateState } from './app-update-checker' +import { useWallAppUpdate } from './use-wall-app-update' +import { useWallAppUpdate as usePageWallAppUpdate } from './use-wall-app-update.web' + +const runtime = vi.hoisted(() => { + const state: { snapshot: AppUpdateState } = { + snapshot: { lastCheckedAt: null, available: null, dismissedVersion: null, checking: false } + } + return state +}) + +vi.mock('./app-update-runtime', () => ({ useAppUpdateState: () => runtime.snapshot })) + +const RELEASE = { version: '0.0.52', url: 'https://example.test/0.0.52' } + +describe('useWallAppUpdate', () => { + it('offers a release dismissed on home, because it is the way past the wall', () => { + runtime.snapshot = { + lastCheckedAt: 1, + available: RELEASE, + dismissedVersion: RELEASE.version, + checking: false + } + expect(useWallAppUpdate()).toEqual(RELEASE) + }) + + it('offers nothing when no release is known', () => { + runtime.snapshot = { + lastCheckedAt: 1, + available: null, + dismissedVersion: null, + checking: false + } + expect(useWallAppUpdate()).toBeNull() + }) +}) + +describe('the page form of useWallAppUpdate', () => { + it('offers nothing', () => { + expect(usePageWallAppUpdate()).toBeNull() + }) + + // CI's page-closure pin does not count modules, so this is the only guard on this page path. + it('imports only types, so the page bundle never carries the checker', () => { + const source = readFileSync(join(import.meta.dirname, 'use-wall-app-update.web.ts'), 'utf8') + const imports = source.split('\n').filter((line) => line.startsWith('import')) + expect(imports.length).toBeGreaterThan(0) + expect(imports.every((line) => line.startsWith('import type '))).toBe(true) + }) +}) diff --git a/mobile/src/app-update/use-wall-app-update.ts b/mobile/src/app-update/use-wall-app-update.ts new file mode 100644 index 00000000000..06b2683a5ce --- /dev/null +++ b/mobile/src/app-update/use-wall-app-update.ts @@ -0,0 +1,8 @@ +import type { KnownAppUpdate } from '../storage/app-update-preferences' +import { useAppUpdateState } from './app-update-runtime' + +// Dismissal is ignored: this release is the way past the wall. The checker's own cadence +// already covers the wall, so nothing is triggered here. +export function useWallAppUpdate(): KnownAppUpdate | null { + return useAppUpdateState().available +} diff --git a/mobile/src/app-update/use-wall-app-update.web.ts b/mobile/src/app-update/use-wall-app-update.web.ts new file mode 100644 index 00000000000..936103c5a9b --- /dev/null +++ b/mobile/src/app-update/use-wall-app-update.web.ts @@ -0,0 +1,6 @@ +import type { KnownAppUpdate } from '../storage/app-update-preferences' + +// The page bundle must not carry the checker; the wall inside the page keeps the fallback link. +export function useWallAppUpdate(): KnownAppUpdate | null { + return null +} diff --git a/mobile/src/browser/mobile-browser-pane-source.test.ts b/mobile/src/browser/mobile-browser-pane-source.test.ts deleted file mode 100644 index 69cd5af5391..00000000000 --- a/mobile/src/browser/mobile-browser-pane-source.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(new URL('./MobileBrowserPane.tsx', import.meta.url), 'utf8') - -function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('MobileBrowserPane source invariants', () => { - it('mirrors handler refs in a layout effect instead of during render', () => { - const mirrorBlock = sliceBetween( - 'useLayoutEffect(() => {', - ' useEffect(() => {\n lastZoomResetUrlRef.current' - ) - - expect(mirrorBlock).toContain('frameMetadataRef.current = frameMetadata') - expect(mirrorBlock).toContain('layoutRef.current = layout') - expect(mirrorBlock).toContain('dialogRef.current = dialog') - expect(mirrorBlock).toContain('zoomRef.current = zoom') - expect(mirrorBlock).toContain('}, [dialog, frameMetadata, layout, zoom])') - }) -}) diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index d3a94f3a7df..575d23dbfbb 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -37,6 +37,8 @@ const hostClient = vi.hoisted(() => ({ vi.mock('../transport/client-context', () => ({ useHostClient: () => hostClient.current })) +// The wall's release offer: the native hook imports the update checker module. +vi.mock('../app-update/use-wall-app-update', () => ({ useWallAppUpdate: () => null })) // Descriptor bookkeeping only; the real recorder reaches the native keychain through host-store. vi.mock('../transport/host-descriptor-recorder', () => ({ recordHostDescriptorFromStatus: vi.fn() diff --git a/mobile/src/components/ProtocolBlockScreen.test.ts b/mobile/src/components/ProtocolBlockScreen.test.ts index c0dd920293d..7a33bfa28c8 100644 --- a/mobile/src/components/ProtocolBlockScreen.test.ts +++ b/mobile/src/components/ProtocolBlockScreen.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { KnownAppUpdate } from '../storage/app-update-preferences' import type { BlockedVerdict } from './ProtocolBlockScreen' import { ProtocolBlockScreen } from './ProtocolBlockScreen' @@ -10,6 +11,14 @@ const nativeTestState = vi.hoisted(() => { return { openUrl: vi.fn(), platform } }) +const wallUpdate = vi.hoisted(() => { + const state: { current: KnownAppUpdate | null } = { current: null } + return state +}) + +// The release the installed app's update check found; the real hook imports the checker module. +vi.mock('../app-update/use-wall-app-update', () => ({ useWallAppUpdate: () => wallUpdate.current })) + vi.mock('react-native', () => ({ Linking: { openURL: nativeTestState.openUrl }, Platform: nativeTestState.platform, @@ -30,7 +39,8 @@ const RELEASES_URL = 'https://github.com/stablyai/orca/releases' let renderer: ReactTestRenderer | null = null -function render(verdict: BlockedVerdict): string { +function render(verdict: BlockedVerdict, mobileUpdate: KnownAppUpdate | null = null): string { + wallUpdate.current = mobileUpdate act(() => { renderer = create(createElement(ProtocolBlockScreen, { verdict })) }) @@ -181,4 +191,43 @@ describe('ProtocolBlockScreen', () => { // The presence precondition for the absence asserted on the refresh wall above. expect(pressableCount()).toBe(2) }) + + describe('with the newest release known', () => { + const release = { + version: '0.0.52', + url: 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.52' + } + + it.each(['ios', 'android'] as const)('opens that exact release on %s', (os) => { + nativeTestState.platform.OS = os + const output = render( + { kind: 'blocked', reason: 'bundle-shell-too-old', schemaVersion: 2 }, + release + ) + expect(output).toContain('Get Orca 0.0.52') + expect(output).not.toContain('Open App Store') + expect(output).not.toContain('Open GitHub Releases') + expect(primaryActionUrl()).toBe(release.url) + }) + + it('leaves the desktop wall on the desktop releases', () => { + const output = render({ kind: 'blocked', reason: 'bundle-unavailable' }, release) + expect(output).not.toContain('Get Orca') + expect(primaryActionUrl()).toBe(RELEASES_URL) + }) + }) + + it.each([ + ['ios', 'Open App Store', 'itms-apps://apps.apple.com/app/orca-ide/id6766130217'], + ['android', 'Open GitHub Releases', RELEASES_URL] + ] as const)('keeps the %s store link when no release is known', (os, label, url) => { + nativeTestState.platform.OS = os + const output = render( + { kind: 'blocked', reason: 'mobile-too-old', desktopVersion: 5, requiredMobileVersion: 9 }, + null + ) + expect(output).toContain(label) + expect(output).not.toContain('Get Orca') + expect(primaryActionUrl()).toBe(url) + }) }) diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index 392a0a5851a..c6d7eba1e22 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -1,3 +1,4 @@ +import { useWallAppUpdate } from '../app-update/use-wall-app-update' import { openExternalLink } from '../platform/external-link' import { useRouteHandoff } from '../navigation/route-handoff' import { Platform, Pressable, StyleSheet, Text, View } from 'react-native' @@ -67,18 +68,22 @@ function blockBody(verdict: BlockedVerdict, remedy: BlockRemedy, storeName: stri export function ProtocolBlockScreen({ verdict }: Props) { const router = useRouteHandoff() + const mobileUpdate = useWallAppUpdate() const remedy = blockRemedy(verdict) // Why: Android APKs ship through GitHub Releases until a Play Store listing exists. const mobileUpdateTarget = Platform.OS === 'ios' ? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' } : { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' } + const mobileAction = mobileUpdate + ? { label: `Get Orca ${mobileUpdate.version}`, url: mobileUpdate.url } + : { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } // No download to offer when the fix is a refetch: reconnecting is what this screen leaves you to do. const primaryAction = remedy === 'refresh-bundle' ? null : remedy === 'update-mobile' - ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } + ? mobileAction : { label: 'Open GitHub Releases', url: RELEASES_URL } const title = blockTitle(remedy) diff --git a/mobile/src/components/mobile-agent-icon-assets.test.ts b/mobile/src/components/mobile-agent-icon-assets.test.ts index f4f6325d806..7020d4b4977 100644 --- a/mobile/src/components/mobile-agent-icon-assets.test.ts +++ b/mobile/src/components/mobile-agent-icon-assets.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import type { TuiAgent } from '../../../src/shared/tui-agent' @@ -17,7 +17,6 @@ const GLYPH_AGENT_IDS = new Set([ const dirname = import.meta.dirname const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons') -const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8') // Why: these agents previously loaded from Google's favicon service, which is // unreachable in some regions/offline (#8451). Every one that lacks a glyph @@ -34,12 +33,4 @@ describe('mobile bundled agent icons', () => { ) } }) - - it('wires every favicon-path agent into the bundled asset map', () => { - for (const id of agentsNeedingBundledIcon) { - expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain( - `agent-icons/${id}.png` - ) - } - }) }) diff --git a/mobile/src/files/files-router-seam-census.test.ts b/mobile/src/files/files-router-seam-census.test.ts deleted file mode 100644 index 360b040b2df..00000000000 --- a/mobile/src/files/files-router-seam-census.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - importsExpoRouterValue, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const FILES_ROOT = import.meta.dirname - -/** - * Which modules here hold a router, so the census cannot pass by seeing nothing. - * - * The walk and the two rules are the seam's, shared with every other domain that runs them; what - * stays here is this domain's own evidence: which of its modules are meant to hold a router. - */ -const ROUTER_HOLDERS = ['MobileFilePreviewScreen.tsx', 'MobileFileExplorerPanel.tsx'] - -describe('the files domain reaches the router through the handoff seam', () => { - const files = productFiles(FILES_ROOT) - - it('walks the modules it is written against', () => { - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - }) - - it('imports no router from expo-router, which the page cannot hand a route back through', () => { - expect(files.filter((name) => importsExpoRouterValue(parse(FILES_ROOT, name)))).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen that holds one', () => { - expect(files.filter((name) => callsRouteHandoff(parse(FILES_ROOT, name))).sort()).toEqual( - [...ROUTER_HOLDERS].sort() - ) - }) -}) diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts deleted file mode 100644 index 617dd030c8a..00000000000 --- a/mobile/src/home-host-edit-navigation-source.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const homeSource = readFileSync(new URL('./home/MobileHomeScreen.tsx', import.meta.url), 'utf8') - -describe('Home host edit navigation wiring', () => { - it('uses the cold-navigator-safe edit transition', () => { - expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/) - expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/) - }) -}) diff --git a/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts b/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts index 249f923c33f..7f210226546 100644 --- a/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts +++ b/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES, - MobileDictationPendingAudioBudget -} from './mobile-dictation-pending-audio-budget' +import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' describe('MobileDictationPendingAudioBudget', () => { it('caps pending raw PCM audio at five seconds', () => { - expect(MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES).toBe(160_000) - const budget = new MobileDictationPendingAudioBudget() expect(budget.tryReserve(159_999)).toBe(true) expect(budget.tryReserve(1)).toBe(true) diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts deleted file mode 100644 index aedc0543bce..00000000000 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') -const nativeCaptureSource = readFileSync( - new URL('../platform/dictation-capture.ts', import.meta.url), - 'utf8' -) - -function sliceSource(sourceText: string, startPattern: string, endPattern: string): string { - const start = sourceText.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = sourceText.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return sourceText.slice(start, end) -} - -function sliceBetween(startPattern: string, endPattern: string): string { - return sliceSource(source, startPattern, endPattern) -} - -describe('useMobileDictation source invariants', () => { - it('publishes live option refs from committed renders before passive Effects flush', () => { - const refDeclarations = sliceBetween( - 'const clientRef = useRef(client)', - 'useLayoutEffect(() => {' - ) - expect(refDeclarations).not.toContain('.current =') - - const mirrorEffect = sliceBetween('useLayoutEffect(() => {', 'const reportError =') - expect(mirrorEffect).toContain('clientRef.current = client') - expect(mirrorEffect).toContain('enabledRef.current = enabled') - expect(mirrorEffect).toContain('onTranscriptRef.current = onTranscript') - expect(mirrorEffect).toContain('onErrorRef.current = onError') - expect(mirrorEffect).toContain('}, [client, enabled, onTranscript, onError])') - }) - - it('carries audio the capture dropped into the same refusal the budget raises', () => { - const chunkHandler = sliceBetween('const sub = capture.onChunk(', 'return () => sub.remove()') - expect(chunkHandler).toContain('if (chunk.droppedBytes > 0)') - expect(chunkHandler).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') - // Only the page can drop: the seam's native half is where the microphone is. - expect(nativeCaptureSource).toContain('droppedBytes: 0') - }) - - it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { - const pendingChunkClears = source.match(/pendingChunksRef\.current\.clear\(\)/g) ?? [] - const pendingAudioResets = source.match(/pendingAudioBudgetRef\.current\.reset\(\)/g) ?? [] - - expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) - }) - - it('closes the capture on every path that ends a dictation', () => { - const closeAudio = sliceBetween( - 'const closeDictationAudio = useCallback(', - 'const failActiveDictation =' - ) - expect(closeAudio).toContain('capture.end()') - const cleanupSlices = [ - sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), - sliceBetween('const abandonDictation = useCallback(', 'const cancel ='), - sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') - ] - for (const cleanupSlice of cleanupSlices) { - expect(cleanupSlice).toContain('closeDictationAudio()') - } - // The capture hands over its tail before the pending sends are taken, or the finish overtakes - // the last chunk. `mobile-dictation-stop-tail.test.tsx` drives the order this pins. - const stopBody = sliceBetween( - 'const stop = useCallback(async () => {', - 'const abandonDictation =' - ) - expect(stopBody.indexOf('capture.end()')).toBeLessThan( - stopBody.indexOf('await Promise.allSettled') - ) - }) - - it('routes an audio interruption through cancel and a disable through the reporting abort', () => { - const interruptionEffect = sliceBetween('capture.onInterruption(', 'return () => sub.remove()') - const disabledEffect = sliceBetween( - 'useEffect(() => {\n if (!enabled) {', - ' }, [abandonDictation, enabled])' - ) - - expect(interruptionEffect).toContain('void cancel()') - // A disable is not the user's cancel, so it carries the reason the composer shows; the user's - // own cancel is the one end that passes none. `mobile-dictation-input-closed-mid-start.test.tsx` - // drives both halves. - expect(disabledEffect).toContain( - 'void abandonDictation(MOBILE_DICTATION_INPUT_CLOSED_ERROR_MESSAGE)' - ) - expect(source).toContain('const cancel = useCallback(() => abandonDictation(null)') - // Which interruptions end a capture is one predicate both seams read, so a page cannot cancel - // on a kind the device ignores. `dictation-capture.test.ts` drives the rule itself. - expect(nativeCaptureSource).toContain('bridgeAudioInterruptionEndsCapture(event.data)') - }) -}) diff --git a/mobile/src/host-screen/host-route-href-encoding.test.ts b/mobile/src/host-screen/host-route-href-encoding.test.ts deleted file mode 100644 index c1ced2afe90..00000000000 --- a/mobile/src/host-screen/host-route-href-encoding.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Every host-route target the host page builds, and whether its host id survives being one segment. - * - * `useLocalSearchParams` and the persisted host store both answer the decoded id, and the store - * admits any non-empty string, so an id carrying `/`, `?`, `#` or whitespace stops being the single - * segment `matchesRoutePattern` reads it as the moment it is interpolated raw. Inside the shell's - * page that decides where a tap goes: the handoff matches the pathname against the shell's - * `pageRoutes` before it chooses between this document and the native stack. - * - * A census rather than six assertions, because the failure is a habit and not a bug: each of these - * sites was written by copying the one next to it, and the seventh will be too. - */ -const MOBILE_ROOT = join(import.meta.dirname, '..', '..') - -/** The files that build a target from the host list and its row sheet. */ -const HOST_PAGE_SOURCES = [ - 'src/host-screen/host-screen-header.tsx', - 'src/host-screen/host-screen-overlays.tsx', - 'src/host-screen/use-host-worktree-actions.ts', - 'src/agent-history/worktree-navigation-actions.ts' -] - -/** - * The two `/h/${...}` reads that are not targets. - * - * Both compare against a pathname the router answers rather than building a link, so encoding them - * would change what a comparison matches instead of what a tap opens. They are listed by the text - * that makes them comparisons, so a line that stopped being one stops being exempt. - */ -const COMPARISONS = ['pathname === `/h/${hostId}`'] - -const HOST_SEGMENT = /`\/h\/\$\{([^}]*)\}/g - -function hostInterpolations(source: string): string[] { - return [...source.matchAll(HOST_SEGMENT)].map((match) => match[1] ?? '') -} - -describe('every host-route target the host page builds', () => { - it('reaches the census, so the assertion below cannot pass on an empty list', () => { - const found = HOST_PAGE_SOURCES.flatMap((path) => - hostInterpolations(readFileSync(join(MOBILE_ROOT, path), 'utf8')) - ) - expect(found.length).toBeGreaterThanOrEqual(6) - }) - - it('encodes the host id, at every site', () => { - const raw: string[] = [] - for (const path of HOST_PAGE_SOURCES) { - const source = readFileSync(join(MOBILE_ROOT, path), 'utf8') - const exempt = COMPARISONS.filter((text) => source.includes(text)).length - const unencoded = hostInterpolations(source).filter( - (expression) => !expression.includes('encodeURIComponent') - ) - // The comparisons are subtracted rather than matched away, so a file that lost its comparison - // and gained a raw target does not come out even. - if (unencoded.length > exempt) { - raw.push(`${path}: ${unencoded.length - exempt} raw`) - } - } - expect(raw).toEqual([]) - }) - - it('keeps the pathname comparisons out of the count for a stated reason, not by accident', () => { - const source = readFileSync( - join(MOBILE_ROOT, 'src/host-screen/use-host-worktree-actions.ts'), - 'utf8' - ) - // Present, so the exemption above is answering something real; and a comparison, so the - // exemption is not quietly covering a target. - expect(source).toContain(COMPARISONS[0]) - }) -}) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index a93f503bd63..9366902a1c8 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -19,6 +19,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) +vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) vi.mock('../transport/client-context', mocks['../transport/client-context']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) @@ -28,7 +29,6 @@ import { act, create } from 'react-test-renderer' import { bridgeId, clientFrame, createFakeRpcClient } from './bridge-host-test-fakes' import { byName, - DEFAULT_ROUTE_GRANTS, trackRenderedScreen, NativeFallback, SCREEN_BUILD_ID as BUILD_ID, @@ -150,7 +150,6 @@ describe('the hybrid shell screen', () => { const tree = await renderScreen(readyState('session-one')) const view = byName(tree, 'ShellViewProbe')[0] expect(view.props.bridgeEnabled).toBe(true) - expect(typeof view.props.onBridgeMessage).toBe('function') // Delivered with no client behind it: there is no host to answer, and nothing throws. await act(async () => { view.props.onBridgeMessage({ nativeEvent: { json: '{"v":1,"type":"ready"}' } }) @@ -634,22 +633,6 @@ describe('a refused update is said beside the page, not in front of it', () => { }) }) -/** - * Last in the file on purpose: it is the case the block above would have poisoned. - * - * Those cases grant the screencast lane and install a client, and before the shared setup reset - * them both, whatever ran next inherited a route granted a lane it never asked for. Deleting the - * reset fails here and nowhere else, because nothing else runs after a case that mutates them. - */ -describe('what one case mutates does not reach the next', () => { - it('starts from the shared route grants and no client', () => { - expect({ grants: dependencies.routeGrants, client: dependencies.client }).toEqual({ - grants: DEFAULT_ROUTE_GRANTS, - client: null - }) - }) -}) - /** * What is on screen between the generation being mounted and the page having a frame. * diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts index 719b21f54f7..189de6bd85b 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.test.ts @@ -8,7 +8,6 @@ import { BRIDGE_MAX_PENDING_REQUESTS, BRIDGE_MAX_REPLY_BYTES, BRIDGE_MAX_REPLY_PARTS, - BRIDGE_DIRECTIONS, BRIDGE_MAX_SUBSCRIPTIONS, isBridgeExternalLinkUrl, readBridgeExternalLinkUrl, @@ -179,10 +178,6 @@ describe('derived caps', () => { }) describe('parseBridgeMessage direction', () => { - it('names both directions and nothing else', () => { - expect(BRIDGE_DIRECTIONS).toEqual(['page-to-shell', 'shell-to-page']) - }) - it('lets a reply past the node cap through, and refuses the same document from the page', () => { const raw = listingReply(5_000) expect(utf8ByteLength(raw)).toBeLessThan(BRIDGE_MAX_MESSAGE_BYTES) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts index 1901b5f368c..b29219f178e 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { BRIDGE_MAX_MESSAGE_BYTES, parseBridgeMessage } from './bridge-caps' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' @@ -95,18 +93,4 @@ describe('the page frame cap at its boundary', () => { expect(read.ok).toBe(false) expect(read.ok ? null : read.refusal).toBe('oversized') }) - - it('reaches the cap through the shared predicate and spells it nowhere else', () => { - // The census half. A copy of the bound inside the client is a second spelling that can only - // drift from the one the reader applies, and it would pass every case above on the day it was - // written. `isBridgeFrameWithinCap` is the one thing both sides call. - const client = readFileSync(join(import.meta.dirname, 'bridge-rpc-client.ts'), 'utf8') - expect(client).toContain('isBridgeFrameWithinCap(') - expect(client).not.toContain('BRIDGE_MAX_MESSAGE_BYTES') - // And the predicate is the reader's own, not a lookalike beside it: the module that parses - // inbound frames is the module that exports it. - const caps = readFileSync(join(import.meta.dirname, 'bridge-caps.ts'), 'utf8') - expect(caps).toContain('export function isBridgeFrameWithinCap') - expect(caps).toContain('if (!isBridgeFrameWithinCap(raw))') - }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts index f4ab3eb9bd3..351810c682a 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { BRIDGE_MAX_MESSAGE_BYTES, parseBridgeMessage, utf8ByteLength } from './bridge-caps' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' @@ -28,18 +26,6 @@ const WIDE_CHARACTER = '漢' const WIDE_CHARACTER_COUNT = 250_000 describe('what the frame cap counts', () => { - it('is the unit both shells count, read from their own sources', () => { - const shell = join(import.meta.dirname, '..', '..', '..', 'modules', 'orca-mobile-web-shell') - const swift = readFileSync(join(shell, 'ios', 'MobileWebShellView.swift'), 'utf8') - const kotlin = readFileSync( - join(shell, 'android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt'), - 'utf8' - ) - // The inbound gate on each platform, by the expression it measures with. - expect(swift).toContain('bridgeGate.accepts(byteCount: json.utf8.count)') - expect(kotlin).toContain('bridgeGate.accepts(json.toByteArray(Charsets.UTF_8).size)') - }) - it('refuses a frame under the cap in code units and over it in bytes', async () => { const pad = WIDE_CHARACTER.repeat(WIDE_CHARACTER_COUNT) // The precondition, without which this is just another oversized frame: a sender reading diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts index 4542949ac32..a1abc7c2d3c 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts @@ -1,14 +1,9 @@ import { describe, expect, it } from 'vitest' import { BrowserScreencastOpcode, - type BrowserScreencastFormat, type BrowserScreencastFrame } from '../../transport/browser-screencast-protocol' import { isRpcResponse } from '../../transport/rpc-response-shape' -import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types' -import type { SendRequestOptions } from '../../transport/unvalidated-rpc-request-port' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_MAX_METHOD_CHARS, @@ -23,16 +18,12 @@ import { } from './bridge-caps' import { BRIDGE_HAPTICS_KINDS, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' import { - BRIDGE_BINARY_FORMATS, - BRIDGE_CONNECTION_STATES, BRIDGE_FAULT_GRANT, - BRIDGE_FOREGROUND_NUDGE_REASONS, BRIDGE_NAVIGATE_BACK_NOTIFY, BRIDGE_PROTOCOL_VERSION, readBridgeClientMessage, readBridgeHostMessage, - type BridgeHostMessage, - type BridgeReplyPayload + type BridgeHostMessage } from './bridge-envelope' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' @@ -592,86 +583,7 @@ describe('the reply reader is the native acceptance predicate', () => { }) }) -describe('type pins', () => { - it('pins the protocol version both sides send', () => { - expect(BRIDGE_PROTOCOL_VERSION).toBe(1) - }) - - it('closes the connection states over the transport union', () => { - const asTransport = (value: (typeof BRIDGE_CONNECTION_STATES)[number]): ConnectionState => value - const asBridge = (value: ConnectionState): (typeof BRIDGE_CONNECTION_STATES)[number] => value - expect(BRIDGE_CONNECTION_STATES.map(asTransport).map(asBridge)).toEqual([ - ...BRIDGE_CONNECTION_STATES - ]) - }) - - it('closes the foreground reasons over the transport union', () => { - const asTransport = ( - value: (typeof BRIDGE_FOREGROUND_NUDGE_REASONS)[number] - ): ForegroundNudgeReason => value - const asBridge = ( - value: ForegroundNudgeReason - ): (typeof BRIDGE_FOREGROUND_NUDGE_REASONS)[number] => value - expect(BRIDGE_FOREGROUND_NUDGE_REASONS.map(asTransport).map(asBridge)).toEqual([ - ...BRIDGE_FOREGROUND_NUDGE_REASONS - ]) - }) - - it('resolves a reply payload to the transport envelope the page hands its callers', () => { - const asRpcResponse = (value: BridgeReplyPayload): RpcResponse => value - const read = readHost(client({ type: 'reply', id: ID, payload: SUCCESS_PAYLOAD })) - const payload = - read.ok && read.message.type === 'reply' && 'payload' in read.message - ? asRpcResponse(read.message.payload) - : null - expect(payload).toEqual(SUCCESS_PAYLOAD) - }) - - it('accepts every option the raw sender declares', () => { - // Both directions: the literal has to satisfy the type, and the type has to have no key the - // literal is missing, so a new option fails to compile until the schema learns it. - const optionKeys: Record = { - timeoutMs: true, - budgetSpansConnect: true, - failWhenDisconnected: true - } - const options: SendRequestOptions = { - timeoutMs: 1000, - budgetSpansConnect: true, - failWhenDisconnected: true - } - expect(Object.keys(optionKeys).toSorted()).toEqual(Object.keys(options).toSorted()) - expect(readClient(client({ type: 'request', id: ID, method: 'm', options })).ok).toBe(true) - }) - - it('bounds the viewport exactly where the desktop terminal contract does', () => { - // A viewport the page sends is replayed on resubscribe by every stream naming that terminal, - // the native screens' included. One the desktop refuses there would kill a stream the page - // never opened, so the two bounds have to be the same number. - // - // Read rather than imported: mobile may not pull a contract *value* into its bundle, and the - // boundary test that enforces that scans this file too. - const contract = readFileSync( - fileURLToPath( - new URL('../../../../src/shared/rpc-contract/terminal-unary-params.ts', import.meta.url) - ), - 'utf8' - ) - const start = contract.indexOf('export const TerminalViewport') - expect(start).toBeGreaterThan(-1) - const declaration = contract.slice(start, contract.indexOf('})', start)) - expect(declaration).toContain(`cols: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_COLS})`) - expect(declaration).toContain(`rows: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_ROWS})`) - }) - - it('closes the binary formats over the screencast protocol', () => { - const asProtocol = (value: (typeof BRIDGE_BINARY_FORMATS)[number]): BrowserScreencastFormat => - value - const asBridge = (value: BrowserScreencastFormat): (typeof BRIDGE_BINARY_FORMATS)[number] => - value - expect(BRIDGE_BINARY_FORMATS.map(asProtocol).map(asBridge)).toEqual([...BRIDGE_BINARY_FORMATS]) - }) - +describe('the screencast frame reader', () => { it('refuses a screencast metadata field that is not a finite number', () => { const keys = [ 'offsetTop', diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts index b25016775f8..7acf280b63f 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts @@ -99,19 +99,6 @@ describe('reading a native verb call', () => { }) describe('the media verbs on the same seam', () => { - it('serves all three, and each is a grant name of its own', () => { - expect([...BRIDGE_NATIVE_VERB_NAMES]).toEqual([ - 'native.clipboard.write', - 'native.clipboard.read', - 'native.media.pick', - 'native.media.read', - 'native.media.release', - 'native.audio.start', - 'native.audio.read', - 'native.audio.stop' - ]) - }) - it('answers each verb with the params it parsed', () => { expect( readBridgeNativeVerbCall({ diff --git a/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts b/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts index 24089338e57..32a82b9f9c0 100644 --- a/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts +++ b/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts @@ -8,7 +8,6 @@ import { PAGE_MOUNT_STATE_KEY, PAGE_SESSION_ID_KEY, shellRouteHref, - stampPageMountState, type PageMountTarget } from './page-bootstrap' import type { BridgeRpcClient, BridgeShellSession } from './bridge-rpc-client' @@ -248,13 +247,3 @@ describe('the url the page writes for a route', () => { ) }) }) - -describe('the mount state attribute', () => { - it('records the last state reached, so the entry can say its script ran', () => { - const target = createTarget() - stampPageMountState(target, 'started') - expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('started') - stampPageMountState(target, 'mounted') - expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('mounted') - }) -}) diff --git a/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts b/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts index c4962ebe16c..46faf5f38c1 100644 --- a/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts +++ b/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts @@ -3,8 +3,6 @@ * what is worth proving is that the page's post reaches the shell's cover over a real frame. */ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { createFakeRpcClient } from '../bridge-host-test-fakes' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' import { createRouteScreenPaintReporter } from './page-first-paint' @@ -33,23 +31,6 @@ describe('the page telling the shell it has a frame', () => { expect(pair.pagePaintCount()).toBe(1) }) - it('is reported by the page entry, from the effect that runs after the tree commits', () => { - // The one call site, pinned: every test above drives the client directly, so a deleted line in - // the entry would leave a shell covering a page that has painted and will never say so. - const entry = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'web-entry', 'index.tsx'), - 'utf8' - ) - expect(entry).toContain('createRouteScreenPaintReporter(') - expect(entry).toContain('client.notifyPagePainted()') - // Handed to the route screen rather than called from the wrapper's own effect, which commits - // while the route's chunk is still arriving and the body is empty. - expect(entry).toContain('RouteScreenPaintProvider') - expect(entry).not.toMatch( - /stampPageMountState\(target, 'mounted'\)\s*\n\s*reportRouteScreenPaint/ - ) - }) - it('costs the shell nothing to hear: no request, no subscription, no reply', async () => { const rpc = createFakeRpcClient() const pair = createFakeBridgePortPair({ rpc }) diff --git a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx index 6cbe33132cf..06e4a6f9f87 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx @@ -118,6 +118,10 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => { parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState } }) +// The wall's release offer: the native hook imports the update checker module. +vi.mock('../app-update/use-wall-app-update', () => ({ + useWallAppUpdate: () => null +})) vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null }) })) vi.mock('./use-page-host-snapshot', () => ({ usePageHostSnapshot: () => ({ diff --git a/mobile/src/mobile-web-shell/generation-store-host-removal-race.test.ts b/mobile/src/mobile-web-shell/generation-store-host-removal-race.test.ts index bf27caef164..87fe1644906 100644 --- a/mobile/src/mobile-web-shell/generation-store-host-removal-race.test.ts +++ b/mobile/src/mobile-web-shell/generation-store-host-removal-race.test.ts @@ -103,7 +103,7 @@ async function seedHosts(store: GenerationStore): Promise { } function indexedHostKeys(fileSystem: FakeGenerationFileSystem): string[] { - return Object.keys(JSON.parse(fileSystem.text(INDEX) ?? '{}')).sort() + return [...JSON.parse(fileSystem.text(INDEX) ?? '[]')].sort() } type Sides = { session: () => GenerationStore; removeHost: (hostId: string) => Promise } diff --git a/mobile/src/mobile-web-shell/generation-store.test.ts b/mobile/src/mobile-web-shell/generation-store.test.ts index 03ed6876a54..e7caf90315d 100644 --- a/mobile/src/mobile-web-shell/generation-store.test.ts +++ b/mobile/src/mobile-web-shell/generation-store.test.ts @@ -72,10 +72,14 @@ async function activate( await store.commitGeneration(await store.stageGeneration(hostKey, result)) } +function hostKeys(count: number): string[] { + return Array.from({ length: count }, (_, index) => deriveHostCacheKey(`host-${index}`)) +} + describe('generation store', () => { it('stages and commits exactly the manifest, with the manifest written last', async () => { const fs = createFakeFileSystem() - const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + const store = createGenerationStore({ fileSystem: fs }) await activate(store, HOST) @@ -94,7 +98,7 @@ describe('generation store', () => { const staged = fs.writes.filter((path) => path.includes('/tmp/')) expect(staged.at(-1)).toBe(`${HOST}/tmp/${build}/manifest.json`) expect(staged).toHaveLength(3) - expect(fs.text('hosts.json')).toBe(JSON.stringify({ [HOST]: 10 })) + expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST])) }) it('reads back the activation it committed', async () => { @@ -154,7 +158,7 @@ describe('generation store', () => { it('treats a second commit of the same build as a no-op', async () => { const fs = createFakeFileSystem() - const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + const store = createGenerationStore({ fileSystem: fs }) await activate(store, HOST) const before = fs.paths() @@ -209,9 +213,8 @@ describe('generation store', () => { it('evicts the least recently activated host past the ceiling', async () => { const fs = createFakeFileSystem() - let clock = 0 - const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) - const hosts = ['a', 'b', 'c', 'd', 'e'].map((name) => deriveHostCacheKey(name)) + const store = createGenerationStore({ fileSystem: fs }) + const hosts = hostKeys(MAX_CACHED_HOSTS + 1) for (const host of hosts) { await activate(store, host) @@ -222,25 +225,23 @@ describe('generation store', () => { for (const host of hosts.slice(1)) { expect((await store.readActiveGeneration(host))?.buildId).toBe(BUILD) } - expect(Object.keys(JSON.parse(fs.text('hosts.json') ?? '{}'))).toHaveLength(MAX_CACHED_HOSTS) + expect(fs.text('hosts.json')).toBe(JSON.stringify(hosts.slice(1))) }) it('evicts a host with no index entry before the least recently activated one', async () => { const fs = createFakeFileSystem() - let clock = 0 - const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) - const oldest = deriveHostCacheKey('a') + const store = createGenerationStore({ fileSystem: fs }) + const [oldest, ...rest] = hostKeys(MAX_CACHED_HOSTS - 1) const orphan = deriveHostCacheKey('orphan') - for (const name of ['a', 'b', 'c']) { - await activate(store, deriveHostCacheKey(name)) + for (const host of [oldest, ...rest]) { + await activate(store, host) } // Activated last, so recency alone would keep it; its index entry is what goes missing. await activate(store, orphan) - const index: Record = JSON.parse(fs.text('hosts.json') ?? '{}') - delete index[orphan] - fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode(JSON.stringify(index)) }) + const order = [oldest, ...rest] + fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode(JSON.stringify(order)) }) - await activate(store, deriveHostCacheKey('d')) + await activate(store, deriveHostCacheKey('one-too-many')) expect(fs.paths().some((path) => path.startsWith(orphan))).toBe(false) expect((await store.readActiveGeneration(oldest))?.buildId).toBe(BUILD) @@ -248,36 +249,102 @@ describe('generation store', () => { it('counts a recommit of the build a host already has as use of that host', async () => { const fs = createFakeFileSystem() - let clock = 0 - const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) - const kept = deriveHostCacheKey('a') - const evicted = deriveHostCacheKey('b') - for (const name of ['a', 'b', 'c', 'd']) { - await activate(store, deriveHostCacheKey(name)) + const store = createGenerationStore({ fileSystem: fs }) + const hosts = hostKeys(MAX_CACHED_HOSTS) + const [kept, evicted] = hosts + for (const host of hosts) { + await activate(store, host) } - // A redownload of the bundle host A already has, which takes the same-build commit path. + // A redownload of the bundle the oldest host already has, which takes the same-build path. await activate(store, kept) - await activate(store, deriveHostCacheKey('e')) + await activate(store, deriveHostCacheKey('one-too-many')) expect(fs.paths().some((path) => path.startsWith(evicted))).toBe(false) expect((await store.readActiveGeneration(kept))?.buildId).toBe(BUILD) }) + it('counts opening a cached generation as use of that host', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const hosts = hostKeys(MAX_CACHED_HOSTS) + for (const host of hosts) { + await activate(store, host) + } + expect((await store.readActiveGeneration(hosts[0]))?.buildId).toBe(BUILD) + + await activate(store, deriveHostCacheKey('one-too-many')) + + expect((await store.readActiveGeneration(hosts[0]))?.buildId).toBe(BUILD) + expect(fs.paths().some((path) => path.startsWith(hosts[1]))).toBe(false) + }) + + it('writes nothing when a read finds no active generation', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + await activate(store, HOST) + const writesBefore = fs.writes.length + + expect(await store.readActiveGeneration(deriveHostCacheKey('never-opened'))).toBeNull() + + expect(fs.writes).toHaveLength(writesBefore) + expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST])) + }) + + it('returns the generation when an open cannot write the index', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const other = deriveHostCacheKey('other') + await activate(store, HOST) + await activate(store, other) + fs.failWritesAt('hosts.json') + + expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD) + expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST, other])) + }) + + it('rewrites the index on an open only when the host is not already the newest', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const [older, newest] = hostKeys(2) + await activate(store, older) + await activate(store, newest) + const before = { text: fs.text('hosts.json'), writes: fs.writes.length } + + expect((await store.readActiveGeneration(newest))?.buildId).toBe(BUILD) + expect({ text: fs.text('hosts.json'), writes: fs.writes.length }).toEqual(before) + + expect((await store.readActiveGeneration(older))?.buildId).toBe(BUILD) + expect(fs.text('hosts.json')).toBe(JSON.stringify([newest, older])) + }) + + it('reads the timestamp record earlier builds wrote as an empty order', async () => { + const fs = createFakeFileSystem() + const store = createGenerationStore({ fileSystem: fs }) + const other = deriveHostCacheKey('other') + fs.seed('hosts.json', { + kind: 'file', + bytes: new TextEncoder().encode(JSON.stringify({ [HOST]: 1, [other]: 2 })) + }) + await activate(store, other) + + expect(fs.text('hosts.json')).toBe(JSON.stringify([other])) + }) + it('never counts or evicts a host that is only mid-download', async () => { const fs = createFakeFileSystem() - let clock = 0 - const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) - const oldest = deriveHostCacheKey('a') + const store = createGenerationStore({ fileSystem: fs }) + const hosts = hostKeys(MAX_CACHED_HOSTS) + const oldest = hosts[0] const downloading = deriveHostCacheKey('downloading') - for (const name of ['a', 'b', 'c', 'd']) { - await activate(store, deriveHostCacheKey(name)) + for (const host of hosts) { + await activate(store, host) } const staged = await store.stageGeneration(downloading, buildResult({})) - await activate(store, deriveHostCacheKey('e')) + await activate(store, deriveHostCacheKey('one-too-many')) - // The ceiling is four cached generations, so the fifth activation evicts the least recently + // The ceiling counts cached generations, so the activation past it evicts the least recently // activated host and leaves the download alone. expect(fs.paths().some((path) => path.startsWith(oldest))).toBe(false) expect(fs.text(`${staged.directory.slice(ROOT.length + 1)}/manifest.json`)).not.toBeNull() @@ -366,7 +433,7 @@ describe('generation store', () => { expect(await store.readActiveGeneration(HOST)).toBeNull() expect((await store.readActiveGeneration(other))?.buildId).toBe(BUILD) - expect(Object.keys(JSON.parse(fs.text('hosts.json') ?? '{}'))).toEqual([other]) + expect(fs.text('hosts.json')).toBe(JSON.stringify([other])) }) it('refuses a rename that did not carry the tree, as Android below API 26 can', async () => { @@ -400,8 +467,8 @@ describe('generation store', () => { it('activates normally when the recency index cannot be read', async () => { const fs = createFakeFileSystem() - const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) - fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode('{}') }) + const store = createGenerationStore({ fileSystem: fs }) + fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode('[]') }) fs.failReadsAt('hosts.json') await activate(store, HOST) @@ -439,26 +506,6 @@ describe('generation store', () => { expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD) }) - it('keeps the host it just activated when the clock jumps backward', async () => { - const fs = createFakeFileSystem() - const times = [100, 200, 300, 400, 1] - let tick = 0 - const store = createGenerationStore({ fileSystem: fs, now: () => times[tick++] ?? 0 }) - const hosts = ['a', 'b', 'c', 'd', 'e'].map((name) => deriveHostCacheKey(name)) - - for (const host of hosts) { - await activate(store, host) - } - - expect((await store.readActiveGeneration(hosts[4]))?.directory).toBe( - `${ROOT}/${hosts[4]}/generations/${BUILD}` - ) - expect(await store.readActiveGeneration(hosts[0])).toBeNull() - for (const host of hosts.slice(1)) { - expect((await store.readActiveGeneration(host))?.buildId).toBe(BUILD) - } - }) - it('returns the activation even when the recency index cannot be written', async () => { const fs = createFakeFileSystem() const store = createGenerationStore({ fileSystem: fs }) @@ -486,16 +533,16 @@ describe('generation store', () => { it('prunes an index entry whose host tree is gone', async () => { const fs = createFakeFileSystem() - const store = createGenerationStore({ fileSystem: fs, now: () => 10 }) + const store = createGenerationStore({ fileSystem: fs }) const stale = deriveHostCacheKey('uninstalled') fs.seed('hosts.json', { kind: 'file', - bytes: new TextEncoder().encode(JSON.stringify({ [stale]: 5 })) + bytes: new TextEncoder().encode(JSON.stringify([stale])) }) await activate(store, HOST) - expect(fs.text('hosts.json')).toBe(JSON.stringify({ [HOST]: 10 })) + expect(fs.text('hosts.json')).toBe(JSON.stringify([HOST])) }) it('refuses a staged handle it did not issue', async () => { @@ -519,8 +566,7 @@ describe('generation store', () => { it('ignores a directory under the cache root that is not a host key', async () => { const fs = createFakeFileSystem() - let clock = 0 - const store = createGenerationStore({ fileSystem: fs, now: () => (clock += 1) }) + const store = createGenerationStore({ fileSystem: fs }) // Whatever else lives under the OS cache directory is not this store's to count or delete. fs.seed('not-a-host-key/stray.txt', { kind: 'file', bytes: new Uint8Array(1) }) const hosts = ['a', 'b', 'c', 'd'].map((name) => deriveHostCacheKey(name)) diff --git a/mobile/src/mobile-web-shell/generation-store.ts b/mobile/src/mobile-web-shell/generation-store.ts index 75a315ca332..53281212294 100644 --- a/mobile/src/mobile-web-shell/generation-store.ts +++ b/mobile/src/mobile-web-shell/generation-store.ts @@ -23,8 +23,8 @@ const GENERATIONS_DIRECTORY_NAME = 'generations' const STAGING_DIRECTORY_NAME = 'tmp' const HOST_INDEX_FILE_NAME = 'hosts.json' -/** The architecture reference's cache ceiling: four hosts, least recently activated evicted. */ -export const MAX_CACHED_HOSTS = 4 +/** Least recently used host evicted past this. */ +export const MAX_CACHED_HOSTS = 6 export type ActiveGeneration = { readonly buildId: string @@ -67,15 +67,14 @@ export type GenerationStore = { forgetHostUpdateFailures(hostId: string): Promise } -/** Recency only, so anything unreadable degrades to "evict this host first". */ -const HostIndexSchema = z.record(z.string(), z.number().int().nonnegative()) +/** Host cache keys, least recently used first. Recency only, so anything unreadable degrades to + * "evict this host first"; the timestamp record earlier builds wrote reads as empty once. */ +const HostOrderSchema = z.array(z.string()) export function createGenerationStore(options: { fileSystem: GenerationFileSystem - now?: () => number }): GenerationStore { const fs = options.fileSystem - const now = options.now ?? Date.now // `StagedGeneration` is structurally typed, so any object of that shape would otherwise let // `commitGeneration` rename over, and `abortStagedGeneration` delete, a directory of the caller's // choosing. Only handles this store minted are honoured. @@ -87,25 +86,30 @@ export function createGenerationStore(options: { const stagingRoot = (hostKey: string): string => joinUri(hostRoot(hostKey), STAGING_DIRECTORY_NAME) - async function readHostIndex(): Promise> { + async function readHostOrder(): Promise { // Unreadable is treated as absent here, unlike a manifest: an index nobody can read costs // eviction order, and the next activation rewrites it whole. const text = await fs.readText(joinUri(fs.rootUri, HOST_INDEX_FILE_NAME)).catch(() => null) - const parsed = text === null ? null : HostIndexSchema.safeParse(parseJson(text)) - return new Map(Object.entries(parsed?.success === true ? parsed.data : {})) + const parsed = text === null ? null : HostOrderSchema.safeParse(parseJson(text)) + return parsed?.success === true ? parsed.data : [] } - async function writeHostIndex(index: ReadonlyMap): Promise { + async function writeHostOrder(order: readonly string[]): Promise { // Recency, not truth: a full disk here must not turn an activation that is already on disk // into a thrown commit, and the next activation rewrites the whole index anyway. await fs - .writeText( - joinUri(fs.rootUri, HOST_INDEX_FILE_NAME), - JSON.stringify(Object.fromEntries(index)) - ) + .writeText(joinUri(fs.rootUri, HOST_INDEX_FILE_NAME), JSON.stringify(order)) .catch(() => undefined) } + /** Use without an eviction pass, because the host count did not change. */ + async function touchHost(hostKey: string): Promise { + const order = await readHostOrder() + if (order.at(-1) !== hostKey) { + await writeHostOrder(withHostLast(order, hostKey)) + } + } + async function listHostDirectories(): Promise { const entries = await fs.list(fs.rootUri) return entries.filter((entry) => entry.isDirectory && isHostCacheKey(entry.name)) @@ -129,26 +133,20 @@ export function createGenerationStore(options: { await fs.delete(hostRoot(hostKey)) } - async function enforceHostLimit(index: Map, activated: string): Promise { + async function enforceHostLimit(order: readonly string[]): Promise { const hosts = await listActivatedHosts() - const present = new Set(hosts) - for (const key of Array.from(index.keys())) { - if (!present.has(key)) { - index.delete(key) - } - } - // A host with no index entry sorts first: the index is recency, not truth, so a lost or + // A host missing from the order sorts first: the index is recency, not truth, so a lost or // truncated one costs eviction order rather than a generation. The host just activated is - // never a candidate, because `now()` is a wall clock: one backward jump would otherwise make - // the newest entry the oldest and evict the tree the caller is about to open. - const candidates = hosts - .filter((host) => host !== activated) - .sort((left, right) => (index.get(left) ?? 0) - (index.get(right) ?? 0)) - for (const host of candidates.slice(0, Math.max(0, hosts.length - MAX_CACHED_HOSTS))) { + // last, so it is never among the evicted. + const evicted = new Set( + [...hosts] + .sort((left, right) => order.indexOf(left) - order.indexOf(right)) + .slice(0, Math.max(0, hosts.length - MAX_CACHED_HOSTS)) + ) + for (const host of evicted) { await dropHostTree(host) - index.delete(host) } - await writeHostIndex(index) + await writeHostOrder(order.filter((key) => hosts.includes(key) && !evicted.has(key))) } async function readActive(hostKey: string): Promise { @@ -242,12 +240,9 @@ export function createGenerationStore(options: { existing?.isDirectory === true && (await fs.fileExists(joinUri(target, MANIFEST_FILE_NAME))) ) { - // Still an activation, so it still counts as use: without this a host that redownloads the - // bundle it already has stays the least recently activated and is evicted first. No eviction - // pass, because the host count did not change. - const index = await readHostIndex() - index.set(staged.hostKey, now()) - await writeHostIndex(index) + // Still an activation: without this a host that redownloads the bundle it already has stays + // the least recently used and is evicted first. + await touchHost(staged.hostKey) await fs.delete(staged.directory) return active } @@ -271,10 +266,17 @@ export function createGenerationStore(options: { await fs.delete(target) throw new Error(`generation ${staged.buildId} did not carry its manifest through the rename`) } - const index = await readHostIndex() - index.set(staged.hostKey, now()) - // Enforced here rather than left to a caller: the four-host ceiling is this module's invariant. - await enforceHostLimit(index, staged.hostKey) + // Enforced here rather than left to a caller: the host ceiling is this module's invariant. + await enforceHostLimit(withHostLast(await readHostOrder(), staged.hostKey)) + return active + } + + async function openActive(hostKey: string): Promise { + const active = await readActive(hostKey) + // An open is use, so a daily host downloaded long ago is not evicted first. + if (active !== null) { + await touchHost(hostKey) + } return active } @@ -304,9 +306,9 @@ export function createGenerationStore(options: { async function deleteHost(hostKey: string): Promise { await dropHostTree(hostKey) - const index = await readHostIndex() - if (index.delete(hostKey)) { - await writeHostIndex(index) + const order = await readHostOrder() + if (order.includes(hostKey)) { + await writeHostOrder(order.filter((key) => key !== hostKey)) } } @@ -322,7 +324,7 @@ export function createGenerationStore(options: { } return { - readActiveGeneration: (hostKey) => serialize(() => readActive(hostKey)), + readActiveGeneration: (hostKey) => serialize(() => openActive(hostKey)), stageGeneration: (hostKey, result) => serialize(() => stage(hostKey, result)), commitGeneration: (staged) => serialize(() => commit(staged)), abortStagedGeneration: (staged) => @@ -339,6 +341,10 @@ export function createGenerationStore(options: { } } +function withHostLast(order: readonly string[], hostKey: string): string[] { + return [...order.filter((key) => key !== hostKey), hostKey] +} + function parseJson(text: string): unknown { try { return JSON.parse(text) diff --git a/mobile/src/mobile-web-shell/media-handle-registry.test.ts b/mobile/src/mobile-web-shell/media-handle-registry.test.ts index 4a96fc955a4..a41c900f5a8 100644 --- a/mobile/src/mobile-web-shell/media-handle-registry.test.ts +++ b/mobile/src/mobile-web-shell/media-handle-registry.test.ts @@ -24,14 +24,6 @@ function refusalOf(run: () => unknown): string | null { throw new Error('that call was expected to refuse and did not') } -describe('the lifetime this registry states', () => { - it('sweeps a staged file five minutes after the last touch', () => { - // A literal, because five minutes is the number the body claims and the docstring derives. - expect(MEDIA_HANDLE_TTL_MS).toBe(5 * 60 * 1000) - expect(MEDIA_HANDLE_TTL_MS).toBe(300_000) - }) -}) - describe('minting handles', () => { let clock = 1_000 let deleted: string[] = [] diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts deleted file mode 100644 index fd23a133375..00000000000 --- a/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -/** - * The hybrid shell flag is the whole of what keeps this feature dark in a native build, so who - * touches it is a product invariant rather than a convention. A second reader is how a dark feature - * stops being dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run - * in a native store build the moment anything flipped it, and none of those would fail a type - * check. - * - * The build-kind fact is the same shape of invariant one level up. `EXPO_PUBLIC_MOBILE_SHELL` is - * what a release workflow sets to build the OTA binary, and a second module spelling it would be a - * second answer to "is this the page's build" that no type check would catch either. - */ -const MOBILE_ROOT = join(import.meta.dirname, '..', '..') -const FLAG_KEY = 'orca:mobileWebShellEnabled' -/** The inlined form. Prose may name the variable; this is the spelling that reads it. */ -const BUILD_SWITCH_READ = 'process.env.EXPO_PUBLIC_' -/** The other spelling Expo's plugin inlines, which is how the rule above would be evaded. */ -const BUILD_SWITCH_BRACKET_READ = "process.env['EXPO_PUBLIC_" -const DEFINITION = 'src/storage/preferences.ts' -/** The one product reader, which only the shared switch decision asks. */ -const FLAG_HOOK = 'src/mobile-web-shell/use-mobile-web-shell-enabled.ts' -/** The one caller of that hook: every route asks this instead, so its list is the whole census. */ -const DECISION = 'src/mobile-web-shell/shell-switch-decision.ts' -const ROUTE = 'app/h/[hostId]/web.tsx' -const HOST_ROUTE = 'app/h/[hostId]/index.tsx' -const AGENT_HISTORY_ROUTE = 'app/h/[hostId]/agent-history/[worktreeId].tsx' -const TASKS_ROUTE = 'app/h/[hostId]/tasks.tsx' -const FILES_ROUTE = 'app/h/[hostId]/files/[worktreeId].tsx' -const FILES_PREVIEW_ROUTE = 'app/h/[hostId]/files/preview/[worktreeId].tsx' -const SOURCE_CONTROL_ROUTE = 'app/h/[hostId]/source-control/[worktreeId].tsx' -const REVIEW_ROUTE = 'app/h/[hostId]/review/[worktreeId].tsx' -const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx' -/** The one switch with no native screen behind it; its route file only re-exports this body. */ -const CATCH_ALL_ROUTE = 'src/mobile-web-shell/catch-all-page-route.tsx' -/** What a switch paints while the decision is `pending`, and the third thing every switch names. */ -const PENDING_SCREEN = 'src/mobile-web-shell/ShellSwitchPendingScreen.tsx' -/** One entry per screen the flag can switch to the page, which is what a review reads. */ -const SWITCHED_ROUTES = [ - HOST_ROUTE, - AGENT_HISTORY_ROUTE, - TASKS_ROUTE, - FILES_ROUTE, - FILES_PREVIEW_ROUTE, - SOURCE_CONTROL_ROUTE, - REVIEW_ROUTE, - SESSION_ROUTE, - CATCH_ALL_ROUTE -] -const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx' -/** The one screen that decides whether that row mounts, which is a build-kind question. */ -const TROUBLESHOOT_ROUTE = 'app/troubleshoot.tsx' -/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files, - * but it is where the native view lives and so the easiest place for a second reader to hide. */ -const TREES = { src: 200, app: 10, modules: 1 } -const SHELL_VIEW = 'modules/orca-mobile-web-shell/src/index.ts' - -function sourceFiles(directory: string): string[] { - return censusSourceFiles(join(MOBILE_ROOT, directory)) - .map((path) => relative(MOBILE_ROOT, path)) - .filter((path) => /\.tsx?$/.test(path) && !path.includes('.test.')) -} - -const SOURCES = Object.keys(TREES) - .flatMap((tree) => sourceFiles(tree)) - .map((path) => ({ - path: path.split('\\').join('/'), - text: readFileSync(join(MOBILE_ROOT, path), 'utf8') - })) - -function filesContaining(needle: string): string[] { - return SOURCES.filter((file) => file.text.includes(needle)) - .map((file) => file.path) - .sort() -} - -/** - * The same matches with the line each was read off, as the failure message for the rules below. - * - * A census that answers only with paths tells a reader which file is wrong and nothing about what - * in it is: the needles here are identifiers, and a file can name one in an import, a call or a - * comment. The snippet is what turns "this list moved" into the edit that moved it. - */ -function matchesOf(needle: string): string { - // Sorted by path then by line number, not as text: `:59:` sorts before `:4:` as a string, which - // reads as a file whose matches are out of order. - return [...SOURCES] - .sort((left, right) => left.path.localeCompare(right.path)) - .flatMap((file) => - file.text - .split('\n') - .flatMap((line, index) => - line.includes(needle) ? [`${file.path}:${index + 1}: ${line.trim()}`] : [] - ) - ) - .join('\n') -} - -describe('who touches the hybrid shell flag', () => { - it('reaches every shipped tree, so the absence assertions below cannot pass vacuously', () => { - const paths = SOURCES.map((file) => file.path) - expect(paths).toContain(DEFINITION) - expect(paths).toContain(FLAG_HOOK) - expect(paths).toContain(DECISION) - expect(paths).toContain(PENDING_SCREEN) - expect(paths).toContain(ROUTE) - for (const route of SWITCHED_ROUTES) { - expect(paths).toContain(route) - } - expect(paths).toContain(DEVELOPER_ROW) - expect(paths).toContain(TROUBLESHOOT_ROUTE) - expect(paths).toContain(SHELL_VIEW) - const trees = Object.keys(TREES) - for (const [tree, floor] of Object.entries(TREES)) { - expect(paths.filter((path) => path.startsWith(`${tree}/`)).length).toBeGreaterThan(floor) - } - expect(paths.filter((path) => !trees.some((tree) => path.startsWith(`${tree}/`)))).toEqual([]) - }) - - it('keeps the storage key itself in one module', () => { - expect(filesContaining(FLAG_KEY)).toEqual([DEFINITION]) - }) - - it('reads the build-time switch in one module', () => { - // `babel-preset-expo` inlines an `EXPO_PUBLIC_*` variable only where it is spelled as a - // literal member expression of `process.env`, so a second read is a second literal to keep in - // step with the workflow input — and a value copied out into a binding is not rewritten at - // all, which reads `undefined` on a device and answers native in a build that is not. - // - // Prose is not a read: the docblocks that name the variable are how a reader finds this - // module, and pinning them would make every wording change a census failure. - expect(filesContaining(BUILD_SWITCH_READ), matchesOf(BUILD_SWITCH_READ)).toEqual([DEFINITION]) - expect( - filesContaining(BUILD_SWITCH_BRACKET_READ), - matchesOf(BUILD_SWITCH_BRACKET_READ) - ).toEqual([]) - }) - - it('answers the build kind through one named function, asked by the flag and the row label', () => { - expect(filesContaining('mobileShellBuildKind'), matchesOf('mobileShellBuildKind')).toEqual( - [DEFINITION, DEVELOPER_ROW].sort() - ) - }) - - it('is read by one hook and by the developer row that writes it, and nowhere else', () => { - expect(filesContaining('loadMobileWebShellEnabled')).toEqual( - [DEFINITION, DEVELOPER_ROW, FLAG_HOOK].sort() - ) - }) - - it('is read by the shared switch decision and by nothing else', () => { - // The narrowest this has ever been, and the reason the rule below is total: a route cannot - // hold a private opinion about the flag — including about the window where it is still `null` - // — without reading it, and this is the only place that reads it. - expect( - filesContaining('useMobileWebShellEnabled'), - matchesOf('useMobileWebShellEnabled') - ).toEqual([DECISION, FLAG_HOOK].sort()) - }) - - it('reaches the switched routes through that decision and no others', () => { - // Each switched route is a screen the flag decides the renderer of, and one more is one more - // place a dark feature could turn itself on. The list grows once per domain series, in the PR - // that switches the route file to MobileWebShellScreen, and never as a side effect of anything - // else. A switched route is inert until MOBILE_WEB_PAGE_ROUTES lists it as well, so an entry - // here can land a PR ahead of that one. - expect(filesContaining('useShellSwitchDecision'), matchesOf('useShellSwitchDecision')).toEqual( - [DECISION, ROUTE, ...SWITCHED_ROUTES].sort() - ) - }) - - it('gives every one of them the same neutral state to paint while the flag is unresolved', () => { - // The rule a sixth switch would otherwise regress past. Reading the flag through the decision - // is not on its own enough: a switch that ignored `pending` and fell through to its native - // screen would satisfy the rule above and still flash native in front of a flag-on user. This - // one says every switch names the neutral screen, which is existence rather than shape — where - // it names it is the route test's business, and `shell-switch-null-flag.test.tsx` drives all - // nine through the states themselves. - expect( - filesContaining('ShellSwitchPendingScreen'), - matchesOf('ShellSwitchPendingScreen') - ).toEqual([PENDING_SCREEN, ROUTE, ...SWITCHED_ROUTES].sort()) - }) - - it('fences the build kind in one place, which the read, the hook and the row gate ask', () => { - // The test that makes a native store build unable to turn the flag on: `__DEV__`, or a binary - // built with the switch set to `ota`. The hook starts its state on it so a native release - // build never reaches the neutral state, which is the same answer `loadMobileWebShellEnabled` - // gives one render later — and two spellings of one build-kind test are two things to keep - // true, where this feature's darkness rests on exactly one. - // - // The Troubleshoot route asks the same question to decide whether to mount the toggle, which - // is the one place a user can switch an OTA build back to the native screens. Asking it rather - // than re-deriving it is why that row and the flag can never disagree about the build. - expect( - filesContaining('mobileWebShellFlagCanBeOn'), - matchesOf('mobileWebShellFlagCanBeOn') - ).toEqual([DEFINITION, FLAG_HOOK, TROUBLESHOOT_ROUTE].sort()) - }) - - it('is written only by the developer row', () => { - expect(filesContaining('saveMobileWebShellEnabled')).toEqual([DEFINITION, DEVELOPER_ROW].sort()) - }) -}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx index bc0c93968c9..2303b0b9e47 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx @@ -19,6 +19,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) +vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) vi.mock('../transport/client-context', mocks['../transport/client-context']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts deleted file mode 100644 index e7d8b6c00d3..00000000000 --- a/mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - expoRouterValueImports, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const SHELL_ROOT = import.meta.dirname - -/** - * The tree that is both halves of the handoff, which is why it needs its own census. - * - * Every other domain here is page code: a screen the shell may serve, where expo-router's own - * router posts no `navigate` and a target outside the page paints Unmatched over it. This tree is - * different — it holds the app end of the seam as well, so the rule cannot be "no router" and has - * to name which modules are the app end. - * - * It is also the tree where the gap showed: `PageRouteUnavailableScreen` shipped with a bare - * `useRouter` and nothing caught it, because the three landed censuses walk `src/session`, - * `src/files` and `src/source-control` and this tree had none. - */ - -/** - * The two modules that ARE the app end of the handoff, and so hold expo-router's own router. - * - * `MobileWebShellScreen` is what hosts the page; its `router.push` is the `onNavigate` the page's - * `navigate` notify arrives at, so routing it through the seam would hand the page's target back to - * the page. `useShellStackPop` is the same for `onNavigateBack`: it pops the native stack the shell - * was pushed onto. Neither has a `.web.*` sibling and neither can run inside the page — the page has - * no shell within it — so neither is a screen the seam exists for. - */ -const SHELL_SIDE_ROUTER_HOLDERS = ['MobileWebShellScreen.tsx', 'use-shell-stack-pop.ts'] - -/** - * The screens here that render on the page and therefore must reach the router through the seam. - * - * One today. `PageRouteUnavailableScreen` is rendered by the catch-all on the device and by its - * `.web.tsx` sibling inside the page, so a bare replace there navigates within the WebView to a - * route the page does not carry instead of leaving it. - */ -const ROUTER_HOLDERS = ['PageRouteUnavailableScreen.tsx'] - -/** - * The expo-router names this tree may import outside those two, and why none is a router. - * - * `useLocalSearchParams` reads the params of the route already mounted and `usePathname` reads - * which route that is. Neither takes a target, so neither can put a screen in front of the page. - * - * A closed list rather than a ban on `useRouter`, for the reason the session census gives: - * expo-router also exports a module-singleton `router` that navigates from a plain function, and a - * rule written against the one spelling would read that as clean. - */ -const NON_NAVIGATING_ROUTER_NAMES = ['useLocalSearchParams', 'usePathname'] - -describe('the mobile-web-shell tree reaches the router through the handoff seam', () => { - const files = productFiles(SHELL_ROOT) - - it('walks the modules it is written against, so the empty list below means something', () => { - expect(files).toEqual(expect.arrayContaining(SHELL_SIDE_ROUTER_HOLDERS)) - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - // The whole tree, not the five files that touch a router: a walk that collapsed to those would - // report an empty finding list for a domain it never read. - expect(files.length).toBeGreaterThan(60) - }) - - it('imports nothing from expo-router that can navigate, outside the two that are the seam', () => { - const offenders = files - .filter((name) => !SHELL_SIDE_ROUTER_HOLDERS.includes(name)) - .map((name) => ({ - name, - imported: expoRouterValueImports(parse(SHELL_ROOT, name)).filter( - (imported) => !NON_NAVIGATING_ROUTER_NAMES.includes(imported) - ) - })) - .filter((entry) => entry.imported.length > 0) - .map((entry) => `${entry.name} (${entry.imported.join(', ')})`) - expect(offenders).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen the page renders', () => { - expect(files.filter((name) => callsRouteHandoff(parse(SHELL_ROOT, name))).sort()).toEqual( - [...ROUTER_HOLDERS].sort() - ) - }) - - it('still holds a real router at the app end, which is what the exemption is for', () => { - // The completeness half: the rule above also passes over a tree whose two seam modules stopped - // navigating at all, which is what it would read as if someone moved them and left this list. - for (const name of SHELL_SIDE_ROUTER_HOLDERS) { - expect(expoRouterValueImports(parse(SHELL_ROOT, name)), name).toContain('useRouter') - } - }) -}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx index 446f57b5062..678253ce146 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx @@ -19,6 +19,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) +vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) vi.mock('../transport/client-context', mocks['../transport/client-context']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx index 2abeff8fd96..a1b81f1bdc4 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx @@ -68,7 +68,7 @@ export const SCREEN_SNAPSHOT = { host: { id: 'host-1', name: 'Host One', endpoint: 'ws://host-1', lastConnected: 3 } } -export const DEFAULT_ROUTE_GRANTS: readonly string[] = [ +const DEFAULT_ROUTE_GRANTS: readonly string[] = [ 'navigate', 'storage', 'externalLink', diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx index 1976c3d700f..02e512a355e 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx @@ -144,6 +144,8 @@ export function screenModuleMocks(dependencies: ScreenDependencies) { parseMobileWebShellLoadState: parseMobileWebShellLoadState } }, + // The wall's release offer: the native hook imports the update checker module. + '../app-update/use-wall-app-update': () => ({ useWallAppUpdate: () => null }), // The real bridge hook runs, so the props it owns are the ones the view is handed here; only the // client lookup is stubbed, because reaching it imports the Expo runtime this test does not have. '../transport/client-context': () => ({ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.test.ts index 832fee81e50..abd4c300bf8 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { createFakeGenerationFileSystem } from './generation-file-system-fake' -import { createGenerationStore } from './generation-store' +import { createGenerationStore, MAX_CACHED_HOSTS } from './generation-store' import { deriveHostCacheKey } from './host-cache-key' import type { MobileWebShellUpdateFailure } from './mobile-web-shell-update-failure' import { @@ -45,11 +45,18 @@ describe('appendUpdateFailure', () => { }) it('holds a ceiling across hosts, evicting the oldest of all', () => { - const kept = appendAll(Array.from({ length: 30 }, (_, at) => failure(`host-${at}`, at))) + const kept = appendAll( + Array.from({ length: MAX_UPDATE_FAILURES + 10 }, (_, at) => failure(`host-${at}`, at)) + ) expect(kept).toHaveLength(MAX_UPDATE_FAILURES) expect(kept[0]?.at).toBe(10) }) + // Pinned here because deriving it would make the failure log import the store that imports it. + it('keeps a full per-host history for every cached host', () => { + expect(MAX_UPDATE_FAILURES).toBe(MAX_UPDATE_FAILURES_PER_HOST * MAX_CACHED_HOSTS) + }) + it('drops a build id that is not a digest rather than keep host text', () => { const [entry] = appendUpdateFailure([], { ...failure('host-1', 0), diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.ts b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.ts index 357ddbe831f..dbca08f5f10 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-log.ts @@ -16,8 +16,8 @@ import { * download from a host that fails every launch, while the file stays a few kilobytes. */ export const MAX_UPDATE_FAILURES_PER_HOST = 5 -/** A ceiling across hosts too, because hosts are not otherwise bounded: four hosts' worth. */ -export const MAX_UPDATE_FAILURES = 20 +/** A ceiling across hosts too, because hosts are not otherwise bounded: six cached hosts' worth. */ +export const MAX_UPDATE_FAILURES = 30 const UPDATE_FAILURE_LOG_FILE_NAME = 'update-failures.json' diff --git a/mobile/src/mobile-web-shell/page-haptics.test.ts b/mobile/src/mobile-web-shell/page-haptics.test.ts index 864aeb7f007..a4abec869a3 100644 --- a/mobile/src/mobile-web-shell/page-haptics.test.ts +++ b/mobile/src/mobile-web-shell/page-haptics.test.ts @@ -7,7 +7,7 @@ * `HapticFeedbackConstants` must not be written twice. */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { BRIDGE_HAPTICS_KINDS, type BridgeHapticsKind } from './bridge/bridge-haptics-notify' +import { BRIDGE_HAPTICS_KINDS } from './bridge/bridge-haptics-notify' /** Annotated rather than asserted: the platform is a two-value union and the log starts empty. */ type MockDevice = { platform: { OS: 'ios' | 'android' }; calls: string[] } @@ -108,10 +108,3 @@ describe('the kinds and the functions behind them', () => { expect(new Set(calls).size).toBe(BRIDGE_HAPTICS_KINDS.length) }) }) - -describe('the kind union', () => { - it('is the five the app has and nothing else', () => { - const kinds: readonly BridgeHapticsKind[] = BRIDGE_HAPTICS_KINDS - expect([...kinds]).toEqual(['mediumImpact', 'selection', 'success', 'error', 'edgeBump']) - }) -}) diff --git a/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts b/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts deleted file mode 100644 index a1d40ccf49a..00000000000 --- a/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * The shell serves no page older than its floor (`MOBILE_WEB_PAGE_VERSION_FLOOR`), so it keeps no - * path for one: no negotiated `accepts`/`reports`, and no WebView resized for the keyboard. The - * retired names are listed so a reintroduction goes red. - */ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = join(import.meta.dirname, '..', '..') -const SHELL_DIR = import.meta.dirname -const RETIRED = new RegExp( - [ - 'softwareKeyboardWindowInset', - 'viewShortenedBy', - 'pageReadsKeyboardInset', - 'shellPageReadsKeyboardInset', - 'shellKeyboardGeometry', - 'BRIDGE_KEYBOARD_INSET_ACCEPT', - 'BRIDGE_ROUTE_UPDATE_ACCEPT', - 'BRIDGE_SAFE_AREA_ACCEPT', - 'BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT', - 'BRIDGE_SHELL_ACCEPTS', - 'BRIDGE_MAX_PAGE_ACCEPTS', - 'pageReportsPaint', - 'pageOwnsSafeArea', - 'shellPageOwnsSafeArea', - 'shellAccepts', - 'PageReadyDeclaration', - "'keyboard-inset'", - "'route-update'", - "'safe-area-insets'", - "'page-client-identity'" - ].join('|') -) -/** The negotiated fields themselves, in code: a `ready` or `init` declaring what it takes or reports. */ -const NEGOTIATED_FIELD = /\.(accepts|reports)\b|\b(accepts|reports)\??:/ -const COMMENT = /^\s*(\/\/|\*|\/\*)/ -const KEYBOARD_SIZES_VIEW = /\b(padding|margin)?(Bottom|bottom|height|Height)\s*:[^,}\n]*keyboard/i - -/** Lines that bring back a path for an older page, or size the view from the keyboard. */ -export function olderPagePaths(source: string, inShell: boolean): number[] { - return source - .split('\n') - .flatMap((line, index) => - RETIRED.test(line) || - (inShell && - ((NEGOTIATED_FIELD.test(line) && !COMMENT.test(line)) || KEYBOARD_SIZES_VIEW.test(line))) - ? [index + 1] - : [] - ) -} - -function sources(dir: string): string[] { - return censusSourceFiles(dir).filter( - (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) - ) -} - -describe('the shell and a page older than its floor', () => { - it('finds the lines it is looking for', () => { - expect(olderPagePaths('style={{ paddingBottom: keyboardInset }}', true)).toEqual([1]) - expect(olderPagePaths("accepts.includes('keyboard-inset')", false)).toEqual([1]) - expect(olderPagePaths('if (accepts.includes(BRIDGE_SAFE_AREA_ACCEPT)) {', false)).toEqual([1]) - expect(olderPagePaths(' accepts: z.array(z.string()).optional(),', true)).toEqual([1]) - expect(olderPagePaths('return message.reports ?? []', true)).toEqual([1]) - expect(olderPagePaths('session.pageOwnsSafeArea', false)).toEqual([1]) - expect( - olderPagePaths('{ paddingTop: insets.top, paddingBottom: insets.bottom }', true) - ).toEqual([]) - expect(olderPagePaths('publishKeyboardInset(keyboardInset)', true)).toEqual([]) - // The field rule is the shell's code: elsewhere, and in prose, `accepts` is an ordinary word. - expect(olderPagePaths('gate.accepts(byteCount)', false)).toEqual([]) - expect(olderPagePaths(' // the answer is what the caller reports: a page', true)).toEqual([]) - }) - - it('keeps no path for one anywhere in the app or the page entry', () => { - const offenders = [ - ...sources(join(MOBILE_DIR, 'src')), - ...sources(join(MOBILE_DIR, 'web-entry')) - ].flatMap((file) => - olderPagePaths(readFileSync(file, 'utf8'), file.startsWith(SHELL_DIR)).map( - (line) => `${relative(MOBILE_DIR, file)}:${line}` - ) - ) - expect(offenders).toEqual([]) - }) -}) diff --git a/mobile/src/mobile-web-shell/shell-screen-route-census.test.ts b/mobile/src/mobile-web-shell/shell-screen-route-census.test.ts deleted file mode 100644 index 1626e376966..00000000000 --- a/mobile/src/mobile-web-shell/shell-screen-route-census.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -const HOST_ROUTES = join(import.meta.dirname, '..', '..', 'app', 'h', '[hostId]') -const PAGE_ROUTE_REGISTRY = join( - import.meta.dirname, - '..', - '..', - '..', - 'config', - 'scripts', - 'mobile-web-page-routes.mjs' -) - -/** - * Every switch that hands a route to the shell asks whether the route is one the page can be - * given, and asks it in one place. - * - * A route the schema refuses is dropped to `null` by `bridge-host.ts` and reaches the phone as an - * `init` naming no screen, which the page answers with "Update Orca to open this workspace" — a - * failure screen in place of the native screen sitting right behind the switch. Three routes had - * each grown their own copy of the call and two had none at all, which is the state this census - * ends: the predicate is `shellScreenRoute`, and a switch that spells it itself has a second - * spelling of a rule that can only drift from the one the page reads. - * - * The walk is over the route tree rather than a list, so a route added later is held to this - * without anyone remembering to add it here. - */ -function hostRouteFiles(directory: string = HOST_ROUTES, prefix = ''): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const name = prefix === '' ? entry.name : `${prefix}/${entry.name}` - if (entry.isDirectory()) { - return hostRouteFiles(join(directory, entry.name), name) - } - return entry.name.endsWith('.tsx') && !entry.name.includes('.test.') ? [name] : [] - }) -} - -/** - * A route file's body: the file itself, or the module its default export comes from. - * - * `[...page].tsx` is the reason: expo-router 55 reads a file's platform from the first dot of its - * stripped name, so a catch-all cannot carry a `.web.tsx` sibling under `app/` without registering - * a second route, and its body lives under `src/` where the stem is plain. Following the export - * keeps the walk derived from the tree rather than from a list beside it. - * - * Fails closed, which is the half that matters. A shape this cannot resolve is a file whose body - * it never read, and an unread body is exactly where a switch that skips `shellScreenRoute` would - * sit — so an unknown shape is named by the census rather than quietly treated as a non-switch. - */ -type RouteBody = - | { readonly kind: 'source'; readonly text: string } - | { readonly kind: 'unresolved'; readonly why: string } - -/** The module specifier a file's default export comes from, or null when the body is local. */ -function defaultExportSource(parsed: ts.SourceFile): string | null | undefined { - const importedFrom = new Map() - for (const statement of parsed.statements) { - if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { - const clause = statement.importClause - if (clause?.name !== undefined) { - importedFrom.set(clause.name.text, statement.moduleSpecifier.text) - } - const bindings = clause?.namedBindings - if (bindings !== undefined && ts.isNamedImports(bindings)) { - for (const element of bindings.elements) { - importedFrom.set(element.name.text, statement.moduleSpecifier.text) - } - } - } - } - for (const statement of parsed.statements) { - // `export { default } from 'x'`, and `export { Body as default } from 'x'`. - if ( - ts.isExportDeclaration(statement) && - statement.moduleSpecifier !== undefined && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.exportClause !== undefined && - ts.isNamedExports(statement.exportClause) && - statement.exportClause.elements.some((element) => element.name.text === 'default') - ) { - return statement.moduleSpecifier.text - } - if (ts.isExportAssignment(statement) && statement.isExportEquals !== true) { - // `export default Body` where `Body` came from an import: the body is over there. Any other - // expression — a function, a class, a local — is this file's own. - return ts.isIdentifier(statement.expression) - ? (importedFrom.get(statement.expression.text) ?? null) - : null - } - // `export default function …` / `export default class …`: the body is here. - if ( - (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && - statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === - true - ) { - return null - } - } - return undefined -} - -function bodyOf(name: string): RouteBody { - const routeFile = join(HOST_ROUTES, name) - const source = readFileSync(routeFile, 'utf8') - const specifier = defaultExportSource(parse(source)) - if (specifier === undefined) { - return { kind: 'unresolved', why: 'no default export this census recognises' } - } - if (specifier === null) { - return { kind: 'source', text: source } - } - if (!specifier.startsWith('.')) { - return { kind: 'unresolved', why: `default export comes from the package "${specifier}"` } - } - const base = resolve(dirname(routeFile), specifier) - for (const extension of ['.tsx', '.ts']) { - if (existsSync(`${base}${extension}`)) { - return { kind: 'source', text: readFileSync(`${base}${extension}`, 'utf8') } - } - } - return { kind: 'unresolved', why: `no module at "${specifier}"` } -} - -/** The body text, for the rules below; an unresolved shape is caught by its own case first. */ -function read(name: string): string { - const body = bodyOf(name) - return body.kind === 'source' ? body.text : '' -} - -/** - * The one switch that hands over a route the rule refuses, on purpose. - * - * `web.tsx` is `__DEV__`-only and its fallback is `Redirect href="/h/"`, not a native - * screen. Adopting the guard there sends a `..` deep link through that redirect to the host route, - * which this PR keeps native, so the developer lands on the host list with nothing said about why - * the page did not open. Handed over instead, the same id reaches the bridge and comes back as the - * host's own failure screen, which is the better verdict for a route whose whole purpose is to - * open the page deliberately; `mobile-web-shell-route.test.tsx` pins that by name. - * - * Exempted here rather than silently unwalked, so the exception is read when it changes. - */ -const HANDS_OVER_UNJUDGED = ['web.tsx'] - -const parse = (source: string): ts.SourceFile => - ts.createSourceFile('route.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) - -function importsNames(parsed: ts.SourceFile, module: string): string[] { - return parsed.statements.flatMap((statement) => { - if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { - return [] - } - if (!statement.moduleSpecifier.text.endsWith(module)) { - return [] - } - const bindings = statement.importClause?.namedBindings - return bindings !== undefined && ts.isNamedImports(bindings) - ? bindings.elements.map((element) => element.name.text) - : [] - }) -} - -/** - * A switch is a route file that imports the shell screen. - * - * The import rather than ` { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === name - ) { - called = true - } - ts.forEachChild(node, visit) - } - ts.forEachChild(parsed, visit) - return called -} - -describe('the switches that hand a route to the shell', () => { - const switches = hostRouteFiles().filter((name) => mountsShell(parse(read(name)))) - - it('walks the directory every registered route lives under', () => { - // The root above is written out rather than derived, so this is what ties it to the manifest: - // a page route outside `/h/[hostId]` would be a switch this census never reads. - const pathnames = [ - ...readFileSync(PAGE_ROUTE_REGISTRY, 'utf8').matchAll(/pathname: '([^']+)'/g) - ].map((match) => match[1]) - expect(pathnames.length).toBeGreaterThan(0) - expect(pathnames.filter((pathname) => !pathname.startsWith('/h/[hostId]'))).toEqual([]) - }) - - /** - * Every route file's body was read, so `switches` is a census and not a sample. - * - * A shape this cannot resolve is a body it never opened, and every rule below would read that - * file as "not a switch" — the one answer a census must never give by default. - */ - it('resolves every route file to a body, naming any shape it cannot', () => { - expect( - hostRouteFiles() - .map((name) => ({ name, body: bodyOf(name) })) - .filter((entry) => entry.body.kind === 'unresolved') - .map((entry) => `${entry.name}: ${entry.body.kind === 'unresolved' ? entry.body.why : ''}`) - ).toEqual([]) - }) - - it('walks the route tree and finds them, so the rules below cannot pass vacuously', () => { - expect(switches.sort()).toEqual([ - '[...page].tsx', - 'agent-history/[worktreeId].tsx', - 'files/[worktreeId].tsx', - 'files/preview/[worktreeId].tsx', - 'index.tsx', - 'review/[worktreeId].tsx', - 'session/[worktreeId].tsx', - 'source-control/[worktreeId].tsx', - 'tasks.tsx', - 'web.tsx' - ]) - }) - - it('asks shellScreenRoute whether the route is one the page can be given', () => { - // Imports it *and* calls it: an import the call no longer reaches is a switch that stopped - // asking while still looking like one. - expect( - switches - .filter((name) => !HANDS_OVER_UNJUDGED.includes(name)) - .filter((name) => { - const parsed = parse(read(name)) - return ( - !importsNames(parsed, 'shell-screen-route').includes('shellScreenRoute') || - !callsName(parsed, 'shellScreenRoute') - ) - }) - ).toEqual([]) - }) - - it('reads the call rather than the import, on a fixture that has only the import', () => { - // The rule the case above cannot show against the tree, every switch there calling what it - // imports: an import with no call is named. - const importOnly = parse( - "import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route'\n" + - "import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'\n" + - 'export default function Route() {\n return \n}\n' - ) - expect(mountsShell(importOnly)).toBe(true) - expect(importsNames(importOnly, 'shell-screen-route')).toContain('shellScreenRoute') - expect(callsName(importOnly, 'shellScreenRoute')).toBe(false) - }) - - it('spells the rule nowhere else, so the page and the app cannot disagree about it', () => { - // The copies this census ends. `shellScreenRoute` is the one caller of the schema outside the - // bridge, and a switch that reaches for it again is writing the second spelling back. - expect(switches.filter((name) => read(name).includes('BridgeInitRouteSchema'))).toEqual([]) - // And the exemption names a switch that exists, so it cannot outlive the file it excuses. - expect(switches).toEqual(expect.arrayContaining(HANDS_OVER_UNJUDGED)) - }) -}) diff --git a/mobile/src/mobile-web-shell/shell-view-surface.test.ts b/mobile/src/mobile-web-shell/shell-view-surface.test.ts deleted file mode 100644 index 7d8817147ca..00000000000 --- a/mobile/src/mobile-web-shell/shell-view-surface.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { colors } from '../theme/mobile-theme' - -/** - * What the WebView paints before its document does, read off both shells. - * - * Neither can be proven from a JVM or a Swift unit test: it is a property of a live view. What is - * checkable is that neither shell gives the view a surface of its own, which is what makes the - * frame behind it — the screen's own, in the app's own colours — the thing on screen. - * - * The two defaults differ and both are wrong. Android's WebView paints nothing under a transparent - * background, which is fine; a WKWebView is opaque by default and paints white, so a dark app - * opening a page flashed white for the whole of the page's boot. - */ -const SHELL = join(import.meta.dirname, '..', '..', 'modules', 'orca-mobile-web-shell') - -function source(relative: string): string { - return readFileSync(join(SHELL, relative), 'utf8') -} - -describe('what a mounted view paints before the page does', () => { - it('gives the Android view no surface of its own', () => { - const kotlin = source( - 'android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt' - ) - expect(kotlin).toContain('view.setBackgroundColor(Color.TRANSPARENT)') - }) - - it('gives the iOS view none either, which is not its default', () => { - const swift = source('ios/MobileWebShellView.swift') - expect(swift).toContain('webView.isOpaque = false') - expect(swift).toContain('webView.backgroundColor = .clear') - expect(swift).toContain('webView.scrollView.backgroundColor = .clear') - }) - - it('leaves the app surface as the one colour behind a page, and it is not black', () => { - // What shows through both: the screen's own root, which is where the token is read. - const screen = readFileSync(join(import.meta.dirname, 'MobileWebShellScreen.tsx'), 'utf8') - expect(screen).toContain('backgroundColor: colors.bgBase') - expect(colors.bgBase).not.toBe('#000000') - }) -}) diff --git a/mobile/src/notifications/desktop-notification-channel.test.ts b/mobile/src/notifications/desktop-notification-channel.test.ts index 95ff41ebe35..01ae373b34c 100644 --- a/mobile/src/notifications/desktop-notification-channel.test.ts +++ b/mobile/src/notifications/desktop-notification-channel.test.ts @@ -1,11 +1,7 @@ -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' +import { ensureDesktopNotificationChannel } from './desktop-notification-channel' vi.mock('expo-notifications', () => ({ AndroidImportance: { HIGH: 'high' }, @@ -31,7 +27,6 @@ describe('ensureDesktopNotificationChannel', () => { 'orca-desktop', expect.objectContaining({ importance: 'high' }) ) - expect(DESKTOP_NOTIFICATION_CHANNEL_ID).toBe('orca-desktop') }) it('does nothing on iOS, which has no notification channels', () => { @@ -48,15 +43,3 @@ describe('ensureDesktopNotificationChannel', () => { 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/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index 6f1b8a4aeec..a55d3809f5f 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { getNotificationNavigationTarget } from './notification-routing' import { @@ -7,8 +6,6 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const rootLayoutSource = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') - function navigationHarness(initialState: HostStackNavigationState | undefined) { const stateListeners = new Set<() => void>() let state = initialState @@ -100,20 +97,6 @@ describe('notification route coordination', () => { it('leaves a host-only notification as a shallow push with nothing to coordinate', () => { expect(getNotificationNavigationTarget({ hostId: 'host-1' })?.sessionTarget).toBeNull() }) - - it('routes notification taps through the coordinated transition, not a bare push', () => { - const start = rootLayoutSource.indexOf('// ─── Notification tap routing ───') - const end = rootLayoutSource.indexOf('// ─── End notification tap routing ───', start) - - // Assert the markers first: a renamed banner would otherwise slice garbage and report a - // missing call instead of the real cause. - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - - const notificationEffect = rootLayoutSource.slice(start, end) - expect(notificationEffect).toContain('openNotificationRoute(target)') - expect(notificationEffect).not.toContain('router.push(') - }) }) it('reuses the current workspace screen and targets the notification pane without pushing', () => { diff --git a/mobile/src/platform/dictation-capture-bridge-budget.test.tsx b/mobile/src/platform/dictation-capture-bridge-budget.test.tsx index 4568fbed910..5bc267d82a9 100644 --- a/mobile/src/platform/dictation-capture-bridge-budget.test.tsx +++ b/mobile/src/platform/dictation-capture-bridge-budget.test.tsx @@ -272,7 +272,6 @@ describe('what one dictation spends of the bridge', () => { }, 60_000) it('ships the batched drain, not the native event rate', () => { - expect(DICTATION_CAPTURE_DRAIN_INTERVAL_MS).toBe(500) expect(DICTATION_CAPTURE_DRAIN_INTERVAL_MS).toBeGreaterThan(DICTATION_NATIVE_EVENT_INTERVAL_MS) }) }) diff --git a/mobile/src/platform/keyboard-geometry-host-os-census.test.ts b/mobile/src/platform/keyboard-geometry-host-os-census.test.ts deleted file mode 100644 index 81b7259395d..00000000000 --- a/mobile/src/platform/keyboard-geometry-host-os-census.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Keyboard and inset arithmetic branches on the host OS, never on `Platform.OS`. - * - * The page runs as `web` while the keyboard it lifts over is the phone's, reported in that OS's own - * terms (iOS counts the home indicator). A `Platform.OS === 'ios'` there takes the Android branch on - * an iPhone page: measured on the iPhone 17 simulator, the terminal lifted 34 pt past native. - */ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) -const GEOMETRY = - /keyboardHeight|keyboardLift|keyboardInset|bottomInset|insets\.bottom|KeyboardAvoidingView|keyboard(Will|Did)(Show|Hide)/ - -const KEYBOARD_READ = - /keyboardHeight|keyboardLift|keyboardInset|useKeyboardOcclusion|useSoftKeyboard/ -const KEYBOARD_IMPORT = /from '[^']*(keyboard|inset)[^']*'/i -const KEYBOARD_FILE = /(keyboard|inset|drawer|lift)[^/]*$/i -const COMMENT = /^\s*(\/\/|\*|\/\*)/ - -/** - * `Platform.OS` reads near keyboard or inset arithmetic, or anywhere in a file that reads the keyboard, - * imports a keyboard/inset module or is named for one: a live-input reopen flag four lines from its - * keyboard height escaped the proximity rule alone. Comments are not reads. - */ -export function platformReadsInGeometry(source: string, file = ''): number[] { - const lines = source.split('\n') - const wholeFile = - KEYBOARD_READ.test(source) || KEYBOARD_IMPORT.test(source) || KEYBOARD_FILE.test(file) - return lines.flatMap((line, index) => - line.includes('Platform.OS') && - !COMMENT.test(line) && - (wholeFile || - lines.slice(Math.max(0, index - 2), index + 3).some((near) => GEOMETRY.test(near))) - ? [index + 1] - : [] - ) -} - -function files(): string[] { - return ['src', 'app'].flatMap((dir) => - censusSourceFiles(fileURLToPath(new URL(`../../${dir}/`, import.meta.url))).filter( - (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) && !file.endsWith('host-os.ts') - ) - ) -} - -describe('keyboard and inset arithmetic', () => { - it('finds the reads it is looking for', () => { - expect( - platformReadsInGeometry("const lift = Platform.OS === 'ios' ? keyboardHeight - 1 : 0") - ).toEqual([1]) - expect( - platformReadsInGeometry("keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}") - ).toEqual([]) - }) - - it('reads the host OS, never Platform.OS', () => { - const offenders = files().flatMap((file) => - platformReadsInGeometry(readFileSync(file, 'utf8'), file).map( - (line) => `${relative(MOBILE_DIR, file)}:${line}` - ) - ) - expect(offenders).toEqual([]) - }) -}) diff --git a/mobile/src/platform/keyboard-geometry-owner-census.test.ts b/mobile/src/platform/keyboard-geometry-owner-census.test.ts deleted file mode 100644 index b3bee07b5f4..00000000000 --- a/mobile/src/platform/keyboard-geometry-owner-census.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Who may ask React Native's `Keyboard` for geometry: the keyboard seam, and one named exception. - * - * react-native-web's `Keyboard` has no `metrics` and an `addListener` that never fires, so a page - * screen that asks it directly either throws (a fill sheet crashed the page on `metrics()`) or waits - * forever. The seam has a web sibling that answers from the window instead; everything else reads it. - */ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) -const SRC_DIR = fileURLToPath(new URL('../', import.meta.url)) - -const SEAM = 'src/platform/keyboard-occlusion.ts' -/** A one-shot "open after the keyboard hides" with a timer fallback; on the page, where the event - * never fires, the timer is what opens the sheet, so it cannot wait forever. */ -const TAB_SHEET_AFTER_HIDE = 'src/session/use-mobile-session-terminal-send-actions.ts' - -const asksKeyboard = (line: string): boolean => - line.includes('Keyboard.metrics(') || line.includes('Keyboard.addListener(') - -function callers(): string[] { - return censusSourceFiles(SRC_DIR) - .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) - .filter((file) => readFileSync(file, 'utf8').split('\n').some(asksKeyboard)) - .map((file) => relative(MOBILE_DIR, file)) - .sort() -} - -describe("React Native's Keyboard geometry", () => { - it('is read only by the keyboard seam and the tab sheet that waits for a hide', () => { - // Also the presence precondition: the seam itself must match, or the matcher is broken. - expect(callers()).toEqual([SEAM, TAB_SHEET_AFTER_HIDE].sort()) - }) -}) diff --git a/mobile/src/platform/microphone-screen-lock-census.test.ts b/mobile/src/platform/microphone-screen-lock-census.test.ts deleted file mode 100644 index 992918930e4..00000000000 --- a/mobile/src/platform/microphone-screen-lock-census.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Who may name the screen: one module, and nothing on the page. - * - * The screen a dictation holds awake is a property of the microphone, so the device module that - * opens the mic takes it and gives it back. Everything above that — the composer's five states, the - * pending-audio budget, where a transcript is routed — has no business naming it, and the page has - * no way to: the verb it used to ask through is gone, along with the tag pools, the timeouts and - * the foreground re-acquire that existed to keep the page's idea of the screen and the device's - * agreeing. - * - * A census rather than a rule in a reviewer's head, because the seam grew back twice from exactly - * the shape this forbids: a page that can ask for a wake tag acquires the bookkeeping to track what - * it asked for. Existence, not shape — any import of the package, and any mention of the verb's - * name at all, from any file but the one owner. - */ -import { readFileSync, readdirSync } from 'node:fs' -import { relative, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) - -/** The one module that may hold the screen: the device calls the microphone's capture makes. */ -const SCREEN_LOCK_OWNER = 'src/platform/native-audio-device.ts' - -/** This file names both things it forbids, so it reads every file but itself. */ -const CENSUS = 'src/platform/microphone-screen-lock-census.test.ts' - -const KEEP_AWAKE_PACKAGE = 'expo-keep-awake' - -/** Built rather than written, so the census does not contain the word it bans. */ -const RETIRED_VERB_WORD = `wake${'lock'}` - -function sourceFiles(): string[] { - const found: string[] = [] - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name) - if (entry.isDirectory()) { - walk(full) - continue - } - if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { - found.push(relative(MOBILE_DIR, full)) - } - } - } - for (const root of ['src', 'app']) { - walk(join(MOBILE_DIR, root)) - } - return found.sort() -} - -/** The line, not just the file: a census that answers "somewhere in 1,400 files" is one a reader - * has to re-run by hand before they can act on it. */ -function hits(match: (line: string) => boolean, skip: readonly string[]): string[] { - const found: string[] = [] - for (const file of sourceFiles()) { - if (skip.includes(file)) { - continue - } - const lines = readFileSync(join(MOBILE_DIR, file), 'utf8').split('\n') - for (const [index, line] of lines.entries()) { - if (match(line)) { - found.push(`${file}:${index + 1}: ${line.trim()}`) - } - } - } - return found -} - -/** An import of the package, which is a module that can hold the screen. A test's `vi.mock` of it - * is not one: the mock exists because the graph reaches the owner, which is the rule holding. */ -const importsKeepAwake = (line: string): boolean => - line.includes(`from '${KEEP_AWAKE_PACKAGE}'`) || line.includes(`require('${KEEP_AWAKE_PACKAGE}')`) - -const namesRetiredVerb = (line: string): boolean => - line.toLowerCase().includes(RETIRED_VERB_WORD.toLowerCase()) - -describe('the one module that may hold the screen awake', () => { - it('is the only one that imports the keep-awake package', () => { - expect(hits(importsKeepAwake, [SCREEN_LOCK_OWNER, CENSUS])).toEqual([]) - }) - - it('does import it, so the absence above is the rule holding and not the match missing', () => { - // The presence precondition every absence needs: the matcher finds the one file that should - // match, so a typo in the package name fails here rather than passing everywhere. - expect(hits(importsKeepAwake, [CENSUS]).map((hit) => hit.split(':')[0])).toEqual([ - SCREEN_LOCK_OWNER - ]) - }) - - it('leaves no page-facing name for the screen anywhere in the app', () => { - // The retired verb, in any casing: a grant row, a schema, a server, a tag, a mock or a comment - // pointing at a seam that no longer exists. - expect(hits(namesRetiredVerb, [CENSUS])).toEqual([]) - }) - - it('reads a tree big enough for those absences to mean something', () => { - // A walk that found nothing would pass all three rules above by having nothing to judge. - expect(sourceFiles().length).toBeGreaterThan(900) - expect(sourceFiles()).toContain(SCREEN_LOCK_OWNER) - }) -}) diff --git a/mobile/src/reanimated-web-mapper-deps.test.ts b/mobile/src/reanimated-web-mapper-deps.test.ts index 7b62ace7821..ed8e0a6e6b2 100644 --- a/mobile/src/reanimated-web-mapper-deps.test.ts +++ b/mobile/src/reanimated-web-mapper-deps.test.ts @@ -238,14 +238,6 @@ describe('reanimated mapper hooks in the web bundle', () => { expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits fade']) }) - it('does not ask for a value the updater only writes, which is an output', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}useAnimatedReaction(() => progress.value, (v) => { opacity.value = v }, [progress])\n` - ) - expect(found).toEqual([]) - }) - it('still asks for one that is read and written', () => { const found = callsMissingDependencies( 'fixture.tsx', diff --git a/mobile/src/rpc-params-contract-type-only-boundary.test.ts b/mobile/src/rpc-params-contract-type-only-boundary.test.ts index e29af9df2d2..a6d9f917e8a 100644 --- a/mobile/src/rpc-params-contract-type-only-boundary.test.ts +++ b/mobile/src/rpc-params-contract-type-only-boundary.test.ts @@ -303,11 +303,14 @@ describe('RPC params contract boundary', () => { // a scanner that stopped reporting, a walk over the wrong root. expect(reaching.length).toBeGreaterThan(0) - const offenders = reaching.flatMap(({ path, source }) => - contractValueImports(path, source).map( - (specifier) => `${relative(mobileRoot, path)} -> ${specifier}` + // A test file is never bundled, and the recorded-request check has to run the host's schemas. + const offenders = reaching + .filter(({ path }) => !/\.test\.tsx?$/.test(path)) + .flatMap(({ path, source }) => + contractValueImports(path, source).map( + (specifier) => `${relative(mobileRoot, path)} -> ${specifier}` + ) ) - ) expect(offenders).toEqual([]) }) }) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 145ea48600c..902022ab03e 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -1,6 +1,6 @@ -import { createElement, StrictMode, type ComponentProps } from 'react' +import { createElement, createRef, StrictMode, type ComponentProps } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { Keyboard } from 'react-native' +import { Keyboard, type TextInput } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { radii, spacing } from '../theme/mobile-theme' import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer' @@ -284,6 +284,26 @@ describe('MobileNativeChatComposer', () => { expect(sendButton().props).toMatchObject({ disabled: true }) }) + it('hands its owner the text field, so Edit can focus it', async () => { + const focus = vi.fn() + const inputRef = createRef() + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + inputRef, + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration + }), + { createNodeMock: (element) => (element.type === 'TextInput' ? { focus } : null) } + ) + }) + inputRef.current?.focus() + expect(focus).toHaveBeenCalledTimes(1) + }) + it('renders a removable thumbnail for each pending image attachment', async () => { const onRemoveAttachment = vi.fn() await act(async () => { diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 99764923c40..c5d3d7759cf 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -36,6 +36,8 @@ const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] type Props = { + /** Lets the owner focus the field, e.g. after Edit moves a queued message into it. */ + inputRef?: React.Ref structuredCommands?: readonly AgentSessionConversationCommand[] /** Controlled composer text — owned by the parent so dictation can write to it. */ value: string @@ -71,6 +73,7 @@ type Props = { } export function MobileNativeChatComposer({ + inputRef, value, onChangeText, onSend, @@ -249,6 +252,7 @@ export function MobileNativeChatComposer({ { }) }) }) - -describe("MobileNativeChatMessage — a subagent's row speaks as that subagent", () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - }) - - function renderAgentRow(agentId: string | undefined, subagentLabel?: string): ReactTestRenderer { - const message: NativeChatMessage = { - id: 'a1', - role: 'assistant', - blocks: [{ type: 'text', text: 'The PR is CLEAN.' }], - timestamp: null, - source: 'transcript', - ...(agentId === undefined ? {} : { agentId }) - } - act(() => { - renderer = create(createElement(MobileNativeChatMessage, { message, subagentLabel })) - }) - return renderer! - } - - const captions = (tree: ReactTestRenderer): ReactTestInstance[] => - tree.root - .findAllByType('Text' as never) - .filter((node) => typeof node.props.accessibilityLabel === 'string') - - it('names the subagent that wrote the row', () => { - const [caption] = captions(renderAgentRow('task-1', 'review the PR')) - expect(caption?.props.accessibilityLabel).toBe('Written by subagent review the PR') - expect(caption?.children.join('')).toBe('review the PR') - }) - - it('still marks the row as a subagent when no loaded roster names it', () => { - const [caption] = captions(renderAgentRow('task-9')) - expect(caption?.children.join('')).toBe('Subagent') - }) - - it("adds nothing to the session's own row", () => { - expect(captions(renderAgentRow(undefined, 'review the PR'))).toEqual([]) - }) - - it('names no one on a settled row whose only content is hidden, and names the live one', () => { - const toolOnly: NativeChatMessage = { - id: 'a2', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'Grep', input: {}, state: 'completed' }], - timestamp: null, - source: 'transcript', - agentId: 'task-1' - } - const renderToolOnly = (activeTurnIsWorking: boolean): ReactTestRenderer => { - act(() => { - renderer = create( - createElement(MobileNativeChatMessage, { - message: toolOnly, - subagentLabel: 'review the PR', - structuredActivityUi: true, - activeTurnIsWorking - }) - ) - }) - return renderer! - } - expect(captions(renderToolOnly(false))).toEqual([]) - act(() => renderer?.unmount()) - expect(captions(renderToolOnly(true))).toHaveLength(1) - }) -}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index a2f14196e36..bb9d962107a 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -4,8 +4,6 @@ import { Image, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' -import { agentJournalItemSubagentId } from '../../../src/shared/agent-session-journal-producer' -import { NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY } from '../../../src/shared/native-chat-subagent-attribution' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -78,8 +76,7 @@ function MobileNativeChatMessageImpl({ turnKey, onToggleTurn, activeTurnIsWorking, - structuredActivityUi = false, - subagentLabel + structuredActivityUi = false }: { message: NativeChatMessage toolsExpanded?: boolean @@ -100,8 +97,6 @@ function MobileNativeChatMessageImpl({ activeTurnIsWorking?: boolean /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean - /** The roster's name for the subagent that wrote this row, when one names it. */ - subagentLabel?: string }): React.JSX.Element { const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' @@ -123,12 +118,6 @@ function MobileNativeChatMessageImpl({ !turnExpanded && !toolsExpanded const showToolRun = tools.length > 0 && !settledToolsHidden - // A subagent's row sits where it happened but speaks as that subagent. A row - // whose only content is hidden behind its settled turn names no one. - const subagentName = - isUser || agentJournalItemSubagentId(message) === null || (prose.length === 0 && !showToolRun) - ? null - : (subagentLabel ?? NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed) const statusRow = turnStatus ? ( - {subagentName !== null ? ( - - {subagentName} - - ) : null} {prose.map((block, index) => ( ({ })) vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' })) +vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } @@ -27,6 +28,7 @@ type Tick = { } function overlayElement(tick: Tick): ReturnType { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members; the rest of the controller is unreachable from it. const controller = { showNativeChat: tick.show ?? true, nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, @@ -38,7 +40,16 @@ function overlayElement(tick: Tick): ReturnType { chatPending: [], chatImagePreviewsByMessageId: {}, chatComposerText: '', - setChatComposerText: vi.fn() + setChatComposerText: vi.fn(), + nativeChatQueued: { + cards: [], + send: vi.fn(), + delete: vi.fn(), + edit: vi.fn(), + pause: null, + resume: vi.fn(), + sessionKey: 'session-a' + } } as unknown as MobileNativeChatController return createElement(MobileNativeChatOverlay, { controller, diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 00598e6a967..3650528d466 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -5,6 +5,7 @@ import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' +import { useMobileNativeChatQueuedSlot } from './use-mobile-native-chat-queued-slot' type Props = { controller: MobileNativeChatController @@ -59,6 +60,16 @@ export function MobileNativeChatOverlay({ controller.nativeChatStreamScopeKey, controller.nativeChatStreamLive ) + const queued = controller.nativeChatQueued + const queuedSlot = useMobileNativeChatQueuedSlot({ + cards: queued.cards, + onSend: queued.send, + onDelete: queued.delete, + onEdit: queued.edit, + pause: queued.pause, + onResume: queued.resume, + sessionKey: queued.sessionKey + }) if (!controller.showNativeChat) { return null } @@ -76,8 +87,7 @@ export function MobileNativeChatOverlay({ turnIndicator={controller.nativeChatTurnIndicator} workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} - activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy} - turnKeysByItemId={controller.nativeChatTurnKeysByItemId} + turnJournal={controller.nativeChatTurnJournal} streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} @@ -90,6 +100,7 @@ export function MobileNativeChatOverlay({ onAnswerQuestion={controller.handleNativeChatQuestionAnswer} permission={controller.nativeChatPermission} onRespondPermission={controller.handleNativeChatRespondPermission} + queuedSlot={queuedSlot} onOpenFile={onOpenFile} hasMore={session.hasMore} loadingEarlier={session.loadingEarlier} diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx new file mode 100644 index 00000000000..b2929f1a1a5 --- /dev/null +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -0,0 +1,386 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages' +import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + CornerDownRight: 'CornerDownRight', + ListEnd: 'ListEnd', + MoreHorizontal: 'MoreHorizontal', + Pause: 'Pause', + Pencil: 'Pencil', + Play: 'Play', + Send: 'Send', + Trash2: 'Trash2' +})) + +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) + +function card( + overrides: Partial & { messageId: string } +): MobileQueuedMessageCard { + return { + text: `text of ${overrides.messageId}`, + state: 'waiting', + paused: false, + needsAttention: false, + caption: null, + ...overrides + } +} + +describe('MobileNativeChatQueuedMessages', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount( + props: Parameters[0] + ): Promise { + const mounted = create(createElement('View')) + renderer = mounted + await act(async () => { + mounted.update(createElement(MobileNativeChatQueuedMessages, props)) + }) + return mounted + } + + function texts(mounted: ReactTestRenderer): unknown[] { + return mounted.root + .findAll((node) => String(node.type) === 'Text') + .map((node) => node.props.children) + } + + function nodeTypes(root: ReactTestRenderer['root']): string[] { + return root.findAll((node) => typeof node.type === 'string').map((node) => String(node.type)) + } + + function flatStyle(style: unknown): Record { + return Object.assign({}, ...[style].flat()) + } + + function sheet(mounted: ReactTestRenderer) { + return mounted.root.find((node) => String(node.type) === 'ActionSheetModal') + } + + it('draws a plain waiting card as one compact row with no caption', async () => { + const mounted = await mount({ cards: [card({ messageId: 'w', text: 'next' })] }) + const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(rows).toHaveLength(1) + expect(texts(mounted)).toEqual(['next', 'Steer']) + // At most two lines: the row has no hover title to read the rest from. + expect( + mounted.root.find((node) => String(node.type) === 'Text' && node.props.children === 'next') + .props.numberOfLines + ).toBe(2) + expect(nodeTypes(rows[0]!)).toContain('ListEnd') + expect(nodeTypes(rows[0]!)).not.toContain('AlertCircle') + }) + + it('divides rows with hairlines inside one box, the first row undivided', async () => { + const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] }) + const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(rows.map((row) => Boolean(flatStyle(row.props.style).borderTopWidth))).toEqual([ + false, + true + ]) + }) + + it('captions a hold under the text on one muted line', async () => { + const mounted = await mount({ + cards: [card({ messageId: 'b', caption: 'Waiting — a message ahead needs attention' })] + }) + const caption = mounted.root.find( + (node) => + String(node.type) === 'Text' && + node.props.children === 'Waiting — a message ahead needs attention' + ) + expect(caption.props.numberOfLines).toBe(1) + expect(flatStyle(caption.props.style).color).not.toBe('#ef4444') + }) + + it("lets a returned card's reason wrap whole, in the destructive color, beside an alert", async () => { + const reason = + 'The provider did not accept this message: Claude does not support the image type .bmp in a steering message.' + const mounted = await mount({ + cards: [ + card({ messageId: 'r', state: 'returned', needsAttention: true, caption: reason }), + card({ messageId: 'w' }) + ] + }) + const caption = mounted.root.find( + (node) => String(node.type) === 'Text' && node.props.children === reason + ) + expect(caption.props.numberOfLines).toBeUndefined() + expect(flatStyle(caption.props.style).color).toBe('#ef4444') + const [returnedRow, waitingRow] = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(nodeTypes(returnedRow!)).toContain('AlertCircle') + expect(nodeTypes(waitingRow!)).not.toContain('AlertCircle') + }) + + it('reads Steer on a waiting card and plain Send on a returned one or one whose own send failed', async () => { + const mounted = await mount({ + cards: [ + card({ messageId: 'failed', paused: true, needsAttention: true }), + card({ messageId: 'waiting' }), + card({ messageId: 'returned', state: 'returned', needsAttention: true }) + ], + onSend: vi.fn(async () => true) + }) + expect(mounted.root.findByProps({ accessibilityLabel: 'Send this message' })).toBeTruthy() + expect( + mounted.root.findByProps({ accessibilityLabel: 'Submit without interrupting the model' }) + ).toBeTruthy() + expect(mounted.root.findByProps({ accessibilityLabel: 'Send this message again' })).toBeTruthy() + expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual([ + 'Send', + 'Steer', + 'Send' + ]) + const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(nodeTypes(rows[0]!)).toContain('Send') + expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') + }) + + it('deletes a card from its trash button', async () => { + const onDelete = vi.fn(async () => true) + const mounted = await mount({ cards: [card({ messageId: 'w' })], onDelete }) + const trash = mounted.root.find( + (node) => + typeof node.type === 'string' && + node.props.accessibilityLabel === 'Delete this queued message' + ) + expect(nodeTypes(trash)).toContain('Trash2') + await act(async () => trash.props.onPress()) + expect(onDelete).toHaveBeenCalledWith('w') + }) + + it('opens the card menu from "…", whose Edit message edits that card once the sheet is gone', async () => { + const onEdit = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'a' }), card({ messageId: 'b', text: 'fix me' })], + onEdit + }) + expect(sheet(mounted).props.visible).toBe(false) + const more = mounted.root.findAllByProps({ accessibilityLabel: 'More actions' }) + expect(more).toHaveLength(2) + await act(async () => more[1]!.props.onPress()) + expect(sheet(mounted).props.visible).toBe(true) + expect(sheet(mounted).props.title).toBe('fix me') + const actions: ActionSheetAction[] = sheet(mounted).props.actions + expect(actions.map((action) => action.label)).toEqual(['Edit message']) + // Focus can only move to the composer once iOS has dismissed the sheet's Modal. + expect(actions[0]!.closeBeforePress).toBe(true) + await act(async () => actions[0]!.onPress()) + expect(onEdit).toHaveBeenCalledWith('b') + await act(async () => sheet(mounted).props.onClose()) + expect(sheet(mounted).props.visible).toBe(false) + }) + + it('offers the card menu on a returned card, so a refused message can be fixed rather than retyped', async () => { + const onEdit = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'returned-1', state: 'returned', needsAttention: true })], + onEdit + }) + await act(async () => + mounted.root.findByProps({ accessibilityLabel: 'More actions' }).props.onPress() + ) + const actions: ActionSheetAction[] = sheet(mounted).props.actions + await act(async () => actions[0]!.onPress()) + expect(onEdit).toHaveBeenCalledWith('returned-1') + }) + + it("closes a card's menu for good once the card leaves, even if it comes back", async () => { + const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] }) + await act(async () => + mounted.root.findAllByProps({ accessibilityLabel: 'More actions' })[0]!.props.onPress() + ) + expect(sheet(mounted).props.visible).toBe(true) + await act(async () => { + mounted.update( + createElement(MobileNativeChatQueuedMessages, { cards: [card({ messageId: 'b' })] }) + ) + }) + expect(sheet(mounted).props.visible).toBe(false) + // A Stop requeues a draft under its own id. + await act(async () => { + mounted.update( + createElement(MobileNativeChatQueuedMessages, { + cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] + }) + ) + }) + expect(sheet(mounted).props.visible).toBe(false) + }) + + it('runs one action per card at a time, however fast its controls are tapped', async () => { + let answer: (done: boolean) => void = () => undefined + const onSend = vi.fn(() => new Promise((resolve) => (answer = resolve))) + const onDelete = vi.fn(async () => true) + const mounted = await mount({ cards: [card({ messageId: 'w' })], onSend, onDelete }) + const button = (label: string) => + mounted.root.find( + (node) => typeof node.type === 'string' && node.props.accessibilityLabel === label + ) + // All three taps land in one frame, before the disabled state can render. + await act(async () => { + button('Submit without interrupting the model').props.onPress() + button('Submit without interrupting the model').props.onPress() + button('Delete this queued message').props.onPress() + }) + expect(onSend).toHaveBeenCalledTimes(1) + expect(onDelete).not.toHaveBeenCalled() + expect(button('More actions').props.disabled).toBe(true) + await act(async () => answer(true)) + expect(button('Submit without interrupting the model').props.disabled).toBe(false) + }) + + describe('a paused queue', () => { + const waiting = card({ messageId: 'waiting-1', text: 'next' }) + + async function mountPaused( + props: Partial[0]> + ): Promise { + return mount({ cards: [waiting], onSend: vi.fn(async () => true), ...props }) + } + + it('heads the box with why the queue is paused, for each reason', async () => { + const rows: readonly [AgentSessionQueuePause['reason'], string][] = [ + ['stopped', 'Queue paused because you interrupted'], + ['restarted', 'Queue paused because Orca restarted'], + ['cleared', 'Queue paused after you cleared the conversation'] + ] + for (const [reason, label] of rows) { + const mounted = await mountPaused({ pause: { reason } }) + expect(texts(mounted)).toContain(label) + } + }) + + it("puts the pause row first in the cards' box, divided from the first card", async () => { + const mounted = await mountPaused({ pause: { reason: 'stopped' } }) + const pauseRow = mounted.root.findByProps({ testID: 'queued-pause-row' }) + const rowIds = pauseRow.parent!.children.flatMap((child) => + typeof child === 'string' ? [] : [child.props.testID] + ) + expect(rowIds).toEqual(['queued-pause-row', 'queued-card-row']) + expect(nodeTypes(pauseRow)).toEqual(expect.arrayContaining(['Pause', 'Play'])) + const [cardRow] = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(flatStyle(cardRow!.props.style).borderTopWidth).toBe(1) + }) + + it('shows no pause row without a pause or without cards', async () => { + expect(texts(await mountPaused({ pause: null }))).not.toContain( + 'Queue paused because you interrupted' + ) + const empty = await mountPaused({ cards: [], pause: { reason: 'stopped' } }) + expect(empty.toJSON()).toBeNull() + }) + + function resumeButton(mounted: ReactTestRenderer) { + return mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) + } + + it('Resume asks the host to lift the pause once, however fast it is tapped twice', async () => { + let answer: (resumed: boolean) => void = () => undefined + const onResume = vi.fn(() => new Promise((resolve) => (answer = resolve))) + const mounted = await mountPaused({ pause: { reason: 'restarted' }, onResume }) + // Both taps land in one frame, before the disabled state can render. + await act(async () => { + resumeButton(mounted).props.onPress() + resumeButton(mounted).props.onPress() + }) + expect(onResume).toHaveBeenCalledTimes(1) + await act(async () => answer(true)) + }) + + it('re-enables Resume when its answer is lost, so it can be tried again', async () => { + let answer: (resumed: boolean) => void = () => undefined + const onResume = vi.fn(() => new Promise((resolve) => (answer = resolve))) + const mounted = await mountPaused({ pause: { reason: 'stopped' }, onResume }) + await act(async () => resumeButton(mounted).props.onPress()) + expect(resumeButton(mounted).props.disabled).toBe(true) + // A lost answer reads as not resumed. + await act(async () => answer(false)) + expect(resumeButton(mounted).props.disabled).toBe(false) + await act(async () => resumeButton(mounted).props.onPress()) + expect(onResume).toHaveBeenCalledTimes(2) + }) + + it('keeps every target inside its row, and announces the pause row', async () => { + const mounted = await mountPaused({ pause: { reason: 'stopped' } }) + const row = mounted.root.findByProps({ testID: 'queued-pause-row' }) + expect(row.props.accessibilityLiveRegion).toBe('polite') + // Android drops touches outside the parent: nothing may pull a row or a button past its edge. + const styled = mounted.root.findAll( + (node) => typeof node.type === 'string' && node.props.style !== undefined + ) + expect(styled.length).toBeGreaterThan(0) + for (const node of styled) { + const style = flatStyle( + typeof node.props.style === 'function' + ? node.props.style({ pressed: false }) + : node.props.style + ) + const margins = Object.entries(style).filter(([key]) => key.startsWith('margin')) + expect(margins.filter(([, value]) => typeof value === 'number' && value < 0)).toEqual([]) + } + }) + + it('keeps Steer on its cards: one card can still go beside the paused rest', async () => { + const onSend = vi.fn(async () => true) + const mounted = await mountPaused({ pause: { reason: 'stopped' }, onSend }) + const steer = mounted.root.findByProps({ + accessibilityLabel: 'Submit without interrupting the model' + }) + expect(texts(mounted)).toContain('Steer') + expect(texts(mounted)).not.toContain('Send') + await act(async () => steer.props.onPress()) + expect(onSend).toHaveBeenCalledWith('waiting-1') + }) + }) + + it('gives every action, Resume included, at least a 44pt touch target in a 44pt row', async () => { + const mounted = await mount({ + cards: [card({ messageId: 'waiting-1' })], + pause: { reason: 'stopped' }, + onSend: vi.fn(async () => true), + onDelete: vi.fn(async () => true), + onEdit: vi.fn(async () => true) + }) + const buttons = mounted.root.findAll( + (node) => typeof node.type === 'string' && node.props.accessibilityRole === 'button' + ) + expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([ + 'Resume sending the queued messages', + 'Submit without interrupting the model', + 'Delete this queued message', + 'More actions' + ]) + for (const button of buttons) { + const style = flatStyle(button.props.style({ pressed: false })) + expect(style.minHeight).toBeGreaterThanOrEqual(44) + expect(style.minWidth).toBeGreaterThanOrEqual(44) + } + for (const row of [ + mounted.root.findByProps({ testID: 'queued-pause-row' }), + ...mounted.root.findAllByProps({ testID: 'queued-card-row' }) + ]) { + expect(flatStyle(row.props.style).minHeight).toBeGreaterThanOrEqual(44) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx new file mode 100644 index 00000000000..57f2fa410ff --- /dev/null +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -0,0 +1,293 @@ +import { useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { + AlertCircle, + CornerDownRight, + ListEnd, + MoreHorizontal, + Pause, + Pencil, + Play, + Send, + Trash2 +} from 'lucide-react-native' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + mobileQueuePauseLabel, + type MobileQueuedMessageCard +} from './mobile-structured-queued-message-cards' +import type { MobileQueuePause } from './mobile-structured-queued-message-feed' +import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls' + +/** The Resume row's in-flight key beside the cards' message ids, which never contain NUL. */ +const RESUME_KEY = '\u0000resume' + +export type MobileNativeChatQueuedMessagesProps = { + cards?: MobileQueuedMessageCard[] + /** Steer for a waiting card, the paused queue's included; plain Send for a card whose own send + * failed, or a returned one. */ + onSend?: (messageId: string) => Promise + onDelete?: (messageId: string) => Promise + /** Copy the card's text into the composer, then delete the card. */ + onEdit?: MobileQueuedMessageEdit + /** The whole queue's pause: the box's first row, with Resume. */ + pause?: MobileQueuePause + onResume?: () => Promise +} + +/** The host-held queued drafts, as one box of compact rows between transcript and + * composer — a queued message is never an optimistic transcript bubble. */ +export function MobileNativeChatQueuedMessages({ + cards, + onSend, + onDelete, + onEdit, + pause, + onResume +}: MobileNativeChatQueuedMessagesProps): React.JSX.Element | null { + // One in-flight action per card; a second tap must not double-consume. The ref + // closes the same-frame double tap the disabled state cannot. + const [busyIds, setBusyIds] = useState>(() => new Set()) + const inFlightRef = useRef(new Set()) + const [menuFor, setMenuFor] = useState(null) + if (!cards || cards.length === 0) { + return null + } + const run = async (messageId: string, action?: (id: string) => Promise) => { + if (inFlightRef.current.has(messageId) || !action) { + return + } + inFlightRef.current.add(messageId) + setBusyIds(new Set(inFlightRef.current)) + try { + await action(messageId) + } finally { + inFlightRef.current.delete(messageId) + setBusyIds(new Set(inFlightRef.current)) + } + } + const resuming = busyIds.has(RESUME_KEY) + // A card that drained or was removed while its menu was open closes the menu, for good: a + // Stop-requeued draft comes back under the same id and must not reopen it. + const menuCard = cards.find((card) => card.messageId === menuFor) + if (menuFor !== null && !menuCard) { + setMenuFor(null) + } + return ( + + {/* One box: the pause row, when shown, is its first row, and each card a row below it. */} + + {pause ? ( + // A polite region, as the app's other notices: React Native has no status role. + + + + {mobileQueuePauseLabel(pause)} + + [ + styles.textAction, + pressed && styles.pressed, + resuming && styles.disabled + ]} + disabled={resuming} + onPress={() => void run(RESUME_KEY, onResume && (() => onResume()))} + > + + Resume + + + ) : null} + {cards.map((card, index) => { + const busy = busyIds.has(card.messageId) + const returned = card.state === 'returned' + // "Steer" submits beside the running turn, the paused queue's cards too; a card whose + // own send failed, or a returned one, is sent again. + const steers = !returned && !card.paused + return ( + 0) && styles.divided]} + > + {card.needsAttention ? ( + + ) : ( + + )} + + {/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */} + + {card.text} + + {card.caption ? ( + // A returned card's reason only reads whole, often at its end; a hold is one line. + + {card.caption} + + ) : null} + + [ + styles.textAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => void run(card.messageId, onSend)} + > + {steers ? ( + + ) : ( + + )} + {steers ? 'Steer' : 'Send'} + + [ + styles.iconAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => void run(card.messageId, onDelete)} + > + + + [ + styles.iconAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => setMenuFor(card.messageId)} + > + + + + ) + })} + + { + if (menuCard) { + void run(menuCard.messageId, onEdit) + } + } + } + ]} + onClose={() => setMenuFor(null)} + /> + + ) +} + +// Every action touches as a 44pt target (platform floor) inside its row: Android drops touches +// outside the parent, so the row is at least that tall and nothing overhangs it. +const MIN_TOUCH_TARGET = 44 + +const styles = StyleSheet.create({ + list: { + marginHorizontal: spacing.lg, + marginVertical: spacing.xs + }, + box: { + backgroundColor: colors.bgPanel, + borderRadius: radii.row, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + overflow: 'hidden' + }, + row: { + minHeight: MIN_TOUCH_TARGET, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingLeft: spacing.md + }, + divided: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + pauseLabel: { + flex: 1, + color: colors.textMuted, + fontSize: typography.metaSize + }, + textColumn: { + flex: 1, + minWidth: 0, + paddingVertical: spacing.sm, + gap: 2 + }, + body: { + color: colors.textPrimary, + fontSize: typography.bodySize + }, + caption: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + captionReturned: { + color: colors.statusRed + }, + textAction: { + minHeight: MIN_TOUCH_TARGET, + minWidth: MIN_TOUCH_TARGET, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: radii.button + }, + iconAction: { + minHeight: MIN_TOUCH_TARGET, + minWidth: MIN_TOUCH_TARGET, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + actionLabel: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '500' + }, + pressed: { + backgroundColor: colors.bgRaised + }, + disabled: { + opacity: 0.5 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index fa29e13c8c8..d224c2e34a0 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,6 +50,7 @@ vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMess vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('./MobileAgentWorkingIndicator', () => ({ MobileAgentWorkingIndicator: 'WorkingIndicator' })) @@ -676,235 +677,4 @@ describe('MobileNativeChatView', () => { vi.useRealTimers() } }) - - describe('structured turn status wiring', () => { - const userTurn = (id: string, text: string): NativeChatMessage => ({ - id, - role: 'user', - blocks: [{ type: 'text', text }], - timestamp: 0, - source: 'transcript' - }) - - function rowProps(id: string): Record { - return (renderedRow(id) as { props: Record }).props - } - - function footerProps(): Record | null { - const list = renderer!.root.find((node) => node.type === 'FlatList') - const footer = list.props.ListFooterComponent as - | { props: Record } - | null - | undefined - return footer?.props ?? null - } - - function workingIndicators(): ReactTestInstance[] { - return renderer!.root.findAll((node) => node.type === 'WorkingIndicator') - } - - it('puts the clock bar under the prompt, activity at the tail, and drops the three dots', async () => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')] - await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - const props = rowProps('u1') - expect(props.structuredActivityUi).toBe(true) - expect(props.turnStatus).toMatchObject({ workedSeconds: null }) - // Nothing reports reasoning, so the tail line reads plain working instead of guessing. - expect(footerProps()).toEqual({ thinking: false, activityText: null }) - expect(listIds().at(-1)).toBe('a1') - expect(props.activeTurnIsWorking).toBe(true) - expect(workingIndicators()).toHaveLength(0) - }) - - it.each([ - { - label: 'structured question', - cardType: 'ChatAsk', - interaction: { - ask: { - questions: [ - { - question: 'Pick destination', - multiSelect: false, - options: [{ label: 'Choice A' }, { label: 'Choice B' }] - } - ] - } - } - }, - { - label: 'question', - cardType: 'ChatQuestion', - interaction: { - question: { - question: 'Pick destination', - options: ['Choice A', 'Choice B'], - multiSelect: false, - allowOther: true, - optionTokens: ['choice-a', 'choice-b'] - } - } - }, - { - label: 'approval', - cardType: 'ChatPermission', - interaction: { - permission: { - title: 'Allow command?', - detail: 'pnpm test', - options: [ - { label: 'Allow', send: 'allow' }, - { label: 'Deny', send: 'deny' } - ] - } - } - } - ])('hides live turn activity for a pending $label without settling it', async (testCase) => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'waiting for input')] - const working = { - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - canStop: true - } - await render({ ...working, ...testCase.interaction }) - - expect(footerProps()).toBeNull() - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent') - ).toHaveLength(1) - expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1) - - await update(working) - expect(footerProps()).toMatchObject({ thinking: false }) - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - }) - - it('reports the live turn as thinking only when its journal says it is reasoning', async () => { - const folded = [userTurn('u1', 'go')] - await render({ - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - turnIndicator: { thinking: true, activityText: null } - }) - expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) - expect(footerProps()).toMatchObject({ thinking: true }) - }) - - it('hands the live row the provider activity copy that outranks its fallbacks', async () => { - const folded = [userTurn('u1', 'go')] - await render({ - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - turnIndicator: { thinking: true, activityText: 'Running pnpm test' } - }) - expect(footerProps()).toMatchObject({ - thinking: true, - activityText: 'Running pnpm test' - }) - }) - - it('keeps the activity copy on the live footer instead of a historical row', async () => { - const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')] - await render({ - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - turnIndicator: { thinking: false, activityText: 'Running pnpm test' } - }) - expect(rowProps('u1')).not.toHaveProperty('turnActivityText') - expect(rowProps('u2')).not.toHaveProperty('turnActivityText') - expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) - }) - - it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { - const folded = [userTurn('u1', 'go')] - await render({ messages: folded, folded, agentWorking: true }) - const props = rowProps('u1') - expect(props.structuredActivityUi).toBe(false) - expect(props.turnStatus).toBeNull() - expect(props.activeTurnIsWorking).toBe(false) - expect(footerProps()).toBeNull() - expect(workingIndicators()).toHaveLength(1) - }) - - it('settles the finished turn to a tappable duration', async () => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] - await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) - expect(footerProps()).toMatchObject({ thinking: false }) - await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) - const settled = rowProps('u1') - expect(settled.turnStatus).toMatchObject({ thinking: false }) - expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf( - 'number' - ) - expect(settled.onToggleTurn).toBeTypeOf('function') - expect(settled.activeTurnIsWorking).toBe(false) - expect(footerProps()).toBeNull() - }) - - it('hangs no status row on an assistant row', async () => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] - await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - expect(rowProps('a1').turnStatus).toBeNull() - // The assistant row still belongs to the live turn, so its tool row stays visible. - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - expect(footerProps()).toMatchObject({ thinking: false }) - }) - - it('does not carry a running turn clock across chat surfaces', async () => { - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - const firstTab = [userTurn('u1', 'first')] - await render({ - messages: firstTab, - folded: firstTab, - structuredActivityUi: true, - agentWorking: true, - sendSurfaceId: 'host\0worktree\0tab-a' - }) - expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) - - vi.setSystemTime(12_000) - const secondTab = [userTurn('u2', 'second')] - await update({ - messages: secondTab, - folded: secondTab, - structuredActivityUi: true, - agentWorking: true, - sendSurfaceId: 'host\0worktree\0tab-b' - }) - - expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) - } finally { - vi.useRealTimers() - } - }) - - it('does not treat pre-user history as part of the live turn', async () => { - const history = [ - assistantTurn('a0', 'before the first prompt'), - userTurn('u1', 'go'), - assistantTurn('a1', 'working') - ] - await render({ - messages: history, - folded: history, - structuredActivityUi: true, - agentWorking: true - }) - - expect(rowProps('a0').activeTurnIsWorking).toBe(false) - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - }) - }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index d0a6c39cba6..469abba5388 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -13,16 +13,14 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { - nativeChatSubagentLabel, - nativeChatSubagentLabels -} from '../../../src/shared/native-chat-subagent-attribution' import type { NativeChatLiveTurnIndicator, NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' +import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' import { buildMobileNativeChatTransientData, mobileNativeChatEmptyState, @@ -37,6 +35,7 @@ import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' +import { NO_QUEUED_SLOT, type MobileQueuedSlotProps } from './use-mobile-native-chat-queued-slot' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' @@ -47,7 +46,7 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' -type Props = { +type Props = MobileQueuedSlotProps & { /** Raw transcript, only for telling "still loading" from "loaded and empty". */ messages: NativeChatMessage[] /** `messages` with noise stripped and tool turns folded in, from the overlay. */ @@ -66,10 +65,8 @@ type Props = { /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null - /** Structured lane: the key the host says anchors the running turn's bar. */ - activeTurnOpenedBy?: string | null - /** Structured lane: host-attributed turn ownership per journal item id. */ - turnKeysByItemId?: ReadonlyMap | null + /** Structured lane: the journal that places each row in its turn. */ + turnJournal?: NativeChatTurnJournal | null /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ /** Interrupt a provider turn. */ onStop?: () => void @@ -155,8 +152,7 @@ export function MobileNativeChatView({ turnIndicator = null, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, + turnJournal = null, onStop, streaming, hasMore, @@ -195,6 +191,7 @@ export function MobileNativeChatView({ onAnswerQuestion, permission, onRespondPermission, + queuedSlot: { cards: queuedCards, composerInputRef: inputRef } = NO_QUEUED_SLOT, onOpenFile, keyboardInset = 0 }: Props): React.JSX.Element { @@ -219,7 +216,6 @@ export function MobileNativeChatView({ }), [messages, folded, streaming, pending, imagePreviewsByMessageId] ) - const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages]) const { listRef, showJumpToTail, @@ -276,8 +272,7 @@ export function MobileNativeChatView({ isWorking: agentWorking === true, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId, + turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId @@ -294,13 +289,20 @@ export function MobileNativeChatView({ onOpenFile={onOpenFile} structuredActivityUi={structuredActivityUi} onToggleTurn={turns.onToggleTurn} - subagentLabel={nativeChatSubagentLabel(subagentLabels, item)} {...turns.resolveRow(index, item)} /> ), - [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns] + [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] ) + const liveStatus = + structuredActivityUi && agentWorking && !hasPendingStructuredInteraction && turns.active ? ( + + ) : null + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 @@ -317,7 +319,7 @@ export function MobileNativeChatView({ item.id} renderItem={renderItem} contentContainerStyle={styles.listContent} @@ -347,17 +349,11 @@ export function MobileNativeChatView({ ) : null } - ListFooterComponent={ - structuredActivityUi && - agentWorking && - !hasPendingStructuredInteraction && - turns.active ? ( - - ) : null - } + ListFooterComponent={mobileNativeChatListFooter( + liveStatus, + turns.waitingRows, + renderItem + )} ListEmptyComponent={ emptyState ? ( @@ -380,6 +376,7 @@ export function MobileNativeChatView({ ) : null} )} + {queuedCards} vi.fn()) +const scrollToOffset = vi.hoisted(() => vi.fn()) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) + +vi.mock('react-native-gesture-handler', () => { + const chain = { + runOnJS: () => chain, + onStart: () => chain, + onUpdate: () => chain + } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) + +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +// Stand-in composer: exposes the view's `handleSend` through a pressable, which is +// the only composer behaviour these banner tests exercise. +vi.mock('./MobileNativeChatComposer', async () => { + const React = await import('react') + return { + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => + React.createElement('Composer', { + ...props, + accessibilityLabel: 'Send message', + onPress: () => props.onSend('hi') + }) + } +}) + +type Overrides = { + messages?: Parameters[0]['messages'] + folded?: Parameters[0]['folded'] + streaming?: string | null + sendErrorMessage?: string | null + onClearSendError?: () => void + inputLockReason?: 'disconnected' | 'waiting' | null + onSend?: (text: string) => Promise + pending?: Parameters[0]['pending'] + structuredActivityUi?: boolean + turnIndicator?: Parameters[0]['turnIndicator'] + agentWorking?: boolean + canStop?: boolean + ask?: Parameters[0]['ask'] + question?: Parameters[0]['question'] + permission?: Parameters[0]['permission'] + sendSurfaceId?: string + keyboardInset?: number + hasMore?: boolean + onLoadEarlier?: () => void +} + +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function chatViewElement(overrides: Overrides): ReturnType { + return createElement(MobileNativeChatView, { + messages: [], + folded: [], + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) +} + +describe('MobileNativeChatView', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => + clearTimeout(handle) + ) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + scrollToEnd.mockReset() + scrollToOffset.mockReset() + vi.unstubAllGlobals() + }) + + async function render(overrides: Overrides = {}): Promise { + await act(async () => { + renderer = create(chatViewElement(overrides)) + }) + } + + async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update(chatViewElement(overrides)) + }) + } + + /** Ids of the rows the list is currently rendering. */ + function listIds(): string[] { + return (list().props.data as { id: string }[]).map((row) => row.id) + } + + function list(): ReactTestInstance { + return renderer!.root.find((node) => String(node.type) === 'FlatList') + } + + function renderedRow(id: string): ReturnType { + const listNode = list() + const data = listNode.props.data as NativeChatMessage[] + const index = data.findIndex((row) => row.id === id) + return listNode.props.renderItem({ item: data[index], index }) + } + + describe('structured turn status wiring', () => { + const userTurn = (id: string, text: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + }) + + function rowProps(id: string): Record { + return (renderedRow(id) as { props: Record }).props + } + + function footerProps(): Record | null { + const list = renderer!.root.find((node) => String(node.type) === 'FlatList') + const footer = list.props.ListFooterComponent as + | { props: Record } + | null + | undefined + return footer?.props ?? null + } + + function workingIndicators(): ReactTestInstance[] { + return renderer!.root.findAll((node) => String(node.type) === 'WorkingIndicator') + } + + it('puts the clock bar under the prompt, activity at the tail, and drops the three dots', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')] + await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) + const props = rowProps('u1') + expect(props.structuredActivityUi).toBe(true) + expect(props.turnStatus).toMatchObject({ workedSeconds: null }) + // Nothing reports reasoning, so the tail line reads plain working instead of guessing. + expect(footerProps()).toEqual({ thinking: false, activityText: null }) + expect(listIds().at(-1)).toBe('a1') + expect(props.activeTurnIsWorking).toBe(true) + expect(workingIndicators()).toHaveLength(0) + }) + + it.each([ + { + label: 'structured question', + cardType: 'ChatAsk', + interaction: { + ask: { + questions: [ + { + question: 'Pick destination', + multiSelect: false, + options: [{ label: 'Choice A' }, { label: 'Choice B' }] + } + ] + } + } + }, + { + label: 'question', + cardType: 'ChatQuestion', + interaction: { + question: { + question: 'Pick destination', + options: ['Choice A', 'Choice B'], + multiSelect: false, + allowOther: true, + optionTokens: ['choice-a', 'choice-b'] + } + } + }, + { + label: 'approval', + cardType: 'ChatPermission', + interaction: { + permission: { + title: 'Allow command?', + detail: 'pnpm test', + options: [ + { label: 'Allow', send: 'allow' }, + { label: 'Deny', send: 'deny' } + ] + } + } + } + ])('hides live turn activity for a pending $label without settling it', async (testCase) => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'waiting for input')] + const working = { + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + canStop: true + } + await render({ ...working, ...testCase.interaction }) + + expect(footerProps()).toBeNull() + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent') + ).toHaveLength(1) + expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1) + + await update(working) + expect(footerProps()).toMatchObject({ thinking: false }) + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + }) + + it('reports the live turn as thinking only when its journal says it is reasoning', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: null } + }) + expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) + expect(footerProps()).toMatchObject({ thinking: true }) + }) + + it('hands the live row the provider activity copy that outranks its fallbacks', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: 'Running pnpm test' } + }) + expect(footerProps()).toMatchObject({ + thinking: true, + activityText: 'Running pnpm test' + }) + }) + + it('keeps the activity copy on the live footer instead of a historical row', async () => { + const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: false, activityText: 'Running pnpm test' } + }) + expect(rowProps('u1')).not.toHaveProperty('turnActivityText') + expect(rowProps('u2')).not.toHaveProperty('turnActivityText') + expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) + }) + + it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { + const folded = [userTurn('u1', 'go')] + await render({ messages: folded, folded, agentWorking: true }) + const props = rowProps('u1') + expect(props.structuredActivityUi).toBe(false) + expect(props.turnStatus).toBeNull() + expect(props.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() + expect(workingIndicators()).toHaveLength(1) + }) + + it('settles the finished turn to a tappable duration', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] + await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) + expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) + expect(footerProps()).toMatchObject({ thinking: false }) + await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) + const settled = rowProps('u1') + expect(settled.turnStatus).toMatchObject({ thinking: false }) + expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf( + 'number' + ) + expect(settled.onToggleTurn).toBeTypeOf('function') + expect(settled.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() + }) + + it('hangs no status row on an assistant row', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] + await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) + expect(rowProps('a1').turnStatus).toBeNull() + // The assistant row still belongs to the live turn, so its tool row stays visible. + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect(footerProps()).toMatchObject({ thinking: false }) + }) + + it('does not carry a running turn clock across chat surfaces', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + const firstTab = [userTurn('u1', 'first')] + await render({ + messages: firstTab, + folded: firstTab, + structuredActivityUi: true, + agentWorking: true, + sendSurfaceId: 'host\0worktree\0tab-a' + }) + expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) + + vi.setSystemTime(12_000) + const secondTab = [userTurn('u2', 'second')] + await update({ + messages: secondTab, + folded: secondTab, + structuredActivityUi: true, + agentWorking: true, + sendSurfaceId: 'host\0worktree\0tab-b' + }) + + expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) + } finally { + vi.useRealTimers() + } + }) + + it('does not treat pre-user history as part of the live turn', async () => { + const history = [ + assistantTurn('a0', 'before the first prompt'), + userTurn('u1', 'go'), + assistantTurn('a1', 'working') + ] + await render({ + messages: history, + folded: history, + structuredActivityUi: true, + agentWorking: true + }) + + expect(rowProps('a0').activeTurnIsWorking).toBe(false) + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + }) + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts new file mode 100644 index 00000000000..518d85f2465 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -0,0 +1,171 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatView } from './MobileNativeChatView' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +// The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +function user(id: string, text: string, queued = false): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'hook', + ...(queued ? { queued: true as const } : {}) + } +} + +function row(itemId: string, sequence: number, body: AgentJournalRenderItem['body']) { + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + turnScope: { kind: 'thread' as const }, + body + } +} + +function renderView( + folded: NativeChatMessage[], + items: AgentJournalRenderItem[] +): { listed: string[]; footer: string[] } { + act(() => { + renderer = create( + createElement(MobileNativeChatView, { + messages: [], + folded, + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + structuredActivityUi: true, + agentWorking: true, + turnJournal: { items, submissions: [] } + }) + ) + }) + const list = renderer!.root.find((node) => String(node.type) === 'FlatList') + let footer: ReactTestRenderer | null = null + act(() => { + footer = create(list.props.ListFooterComponent) + }) + const drawn = footer!.root + .findAll((node) => String(node.type) === 'LiveStatus' || String(node.type) === 'ChatMessage') + .map((node) => (String(node.type) === 'LiveStatus' ? 'status' : node.props.message.id)) + act(() => footer!.unmount()) + return { + listed: list.props.data.map((message: NativeChatMessage) => message.id), + footer: drawn + } +} + +it('draws a message waiting behind a running /compact after the live status, not in the list', () => { + const drawn = renderView( + [user('compact', '/compact'), user('held', 'Say DONE', true)], + [ + row('compact', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + row('command-turn', 2, { + kind: 'turn', + turnId: 'compact:1', + state: 'running', + userItemId: 'compact', + startedAt: 1 + }), + row('held', 3, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Say DONE' }] + }) + ] + ) + + expect(drawn).toEqual({ listed: ['compact'], footer: ['status', 'held'] }) +}) + +it('keeps a steer on its way into an ordinary running turn in the list, above the live status', () => { + const drawn = renderView( + [user('ask', 'List three fruits'), user('steer', 'Make it four', true)], + [ + row('ask', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + row('turn', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + userItemId: 'ask', + startedAt: 1 + }), + row('steer', 3, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Make it four' }] + }) + ] + ) + + expect(drawn).toEqual({ listed: ['ask', 'steer'], footer: ['status'] }) +}) diff --git a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts deleted file mode 100644 index 03b5398939d..00000000000 --- a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' - -const runtimeSource = readMobileSessionRouteSource('./use-mobile-session-terminal-runtime.ts') -const nativeChatSource = readMobileSessionRouteSource( - './use-mobile-session-native-chat-dictation.ts' -) -const sendActionsSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-send-actions.ts' -) -const commandDockSource = readMobileSessionRouteSource('./MobileSessionCommandDock.tsx') -const tabApplicationSource = readMobileSessionRouteSource('./use-mobile-session-tab-application.ts') -const terminalListSource = readMobileSessionRouteSource('./use-mobile-session-terminal-list.ts') -const startupSource = readMobileSessionRouteSource('./use-mobile-session-startup.ts') -const bufferedDraftHookSource = readMobileSessionRouteSource( - '../terminal/use-buffered-terminal-drafts.ts' -) -const keyboardDismissalHookSource = readMobileSessionRouteSource( - './use-agent-send-keyboard-dismissal.ts' -) - -function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { - const start = source.indexOf(anchorStart) - expect(start).toBeGreaterThanOrEqual(0) - // Why: a duplicated start anchor would silently slice the wrong region. - expect(source.indexOf(anchorStart, start + 1)).toBe(-1) - const end = source.indexOf(anchorEnd, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end + anchorEnd.length) -} - -describe('terminal send keyboard dismissal wiring', () => { - it('gates the dismissal on the agent-session predicate', () => { - const slice = sourceSlice( - sendActionsSource, - 'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(', - 'getSendCompletionGeneration\n )' - ) - expect(slice).toContain('dismissSoftwareKeyboard') - expect(keyboardDismissalHookSource).toContain( - 'shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)' - ) - expect(keyboardDismissalHookSource).toContain( - 'origin.generation === getSendCompletionGeneration()' - ) - expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()') - expect(keyboardDismissalHookSource).toContain('return useCallback(') - expect(sendActionsSource).toContain( - "import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'" - ) - }) - - it('invalidates pending terminal sends when the focused input surface changes', () => { - const slice = sourceSlice( - nativeChatSource, - 'const getSendCompletionGeneration = useMobileSendCompletionGeneration({', - '})' - ) - expect(nativeChatSource).toContain( - 'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`' - ) - expect(slice).toContain( - 'surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])' - ) - }) - - it('dismisses after the live input submits, which is the only Enter path', () => { - // terminal-live-input.ts deliberately keeps Enter off the key map, so - // submitLiveInput is the single send seam for the live field. - const slice = sourceSlice( - sendActionsSource, - 'const submitLiveInput = useCallback(() => {', - 'useTerminalTextFieldSubmitBinding(liveInputRef, submitLiveInput)' - ) - expect(slice).toContain('const submit = handleLiveInputSubmit()') - expect(slice).toContain('generation: getSendCompletionGeneration()') - expect(slice).toContain('interaction: getLiveInteractionGeneration()') - expect(slice).toContain('sendOrigin.interaction === getLiveInteractionGeneration()') - expect(slice).toContain('dismissKeyboardAfterAgentSend(') - }) - - it('gives the live field both Enter paths and neither blur', () => { - // The page reaches submitLiveInput through the ref binding, because react-native-web withholds - // onSubmitEditing under an open composition; native reaches it through the editor action. - const slice = sourceSlice( - commandDockSource, - 'ref={bindLiveInputField}', - 'importantForAutofill="no"' - ) - expect(slice).toContain('onSubmitEditing={submitLiveInput}') - // Explicit dismissal replaces RN's blur, which stays off so a shell send - // does not drop focus. - expect(slice).toContain('blurOnSubmit={false}') - }) - - it('dismisses the buffered command send only once the write is accepted', () => { - const slice = sourceSlice( - sendActionsSource, - 'async function handleSend() {', - 'async function handleAccessoryKey(' - ) - const acceptedAt = slice.indexOf( - 'const accepted = terminalInputSend.interpret(response) === true' - ) - const restoreAt = slice.indexOf('restoreRejectedDraft()', acceptedAt) - const dismissAt = slice.indexOf('dismissKeyboardAfterAgentSend(') - const responseAt = slice.indexOf('const response = await terminalInputSend.request(') - const catchAt = slice.indexOf('} catch {') - expect(dismissAt).toBeGreaterThan(0) - expect(responseAt).toBeGreaterThan(0) - expect(acceptedAt).toBeGreaterThan(responseAt) - expect(restoreAt).toBeGreaterThan(acceptedAt) - expect(dismissAt).toBeGreaterThan(responseAt) - expect(slice).toContain( - 'const draftUnchanged =\n accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)' - ) - expect(slice).toContain('dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)') - expect(catchAt).toBeGreaterThan(0) - // Both resolved rejections and transport failures restore the raw draft. - expect(dismissAt).toBeLessThan(catchAt) - expect(slice.slice(catchAt)).not.toContain('dismissKeyboardAfterAgentSend(') - expect(slice.slice(catchAt)).toContain('restoreRejectedDraft()') - }) - - it('keeps buffered Return focused until accepted-agent dismissal runs', () => { - const slice = sourceSlice( - commandDockSource, - 'ref={bindCommandField}', - 'onSubmitEditing={() => void handleSend()}' - ) - expect(slice).toContain('blurOnSubmit={false}') - }) - - it('restores a rejected buffered draft by origin without generation fencing', () => { - const sendSlice = sourceSlice( - sendActionsSource, - 'async function handleSend() {', - 'async function handleAccessoryKey(' - ) - const originAt = sendSlice.indexOf('handle: activeHandle') - const requestAt = sendSlice.indexOf('await terminalInputSend.request(') - const restoreSlice = sourceSlice( - sendActionsSource, - 'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(', - 'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)' - ) - expect(originAt).toBeGreaterThan(0) - expect(originAt).toBeLessThan(requestAt) - expect(restoreSlice).toContain('activeHandle,\n draft') - expect(bufferedDraftHookSource).not.toContain('getSendCompletionGeneration()') - expect(bufferedDraftHookSource).toContain( - 'restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)' - ) - expect(sendSlice.match(/restoreRejectedDraft\(\)/g)).toHaveLength(2) - expect(keyboardDismissalHookSource).toContain( - 'origin.generation === getSendCompletionGeneration()' - ) - }) - - it('keeps buffered draft callbacks scoped to terminal surfaces and prunes ended tabs', () => { - expect(bufferedDraftHookSource).toContain('const handle = activeHandleRef.current') - expect(bufferedDraftHookSource).toContain('invalidateBufferedTerminalDraftRestoration(') - expect(bufferedDraftHookSource).toContain( - 'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))' - ) - expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(') - expect(runtimeSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)') - expect(tabApplicationSource).toContain( - 'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {' - ) - const routeResetSlice = sourceSlice( - startupSource, - '// Why: Expo reuses this screen across worktrees;', - 'clearDelayedActionTimers()\n }' - ) - expect(routeResetSlice).toContain('bufferedTerminalDraftState.resetDrafts()') - expect(routeResetSlice).toContain('bufferedTerminalDraftState.clearPendingRestorations()') - }) - - it('bounds buffered drafts on the terminal.list sweep, against the retained set', () => { - // The drafts record and the pending-restoration map both live as long as the - // session screen does; this one call is the only thing that bounds either. - const slice = sourceSlice( - terminalListSource, - 'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))', - 'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))' - ) - expect(slice).toContain('const retainedHandles = resolveRetainedTerminalHandles(pruneContext)') - expect(slice).toContain('bufferedTerminalDraftState.pruneDrafts(retainedHandles)') - // Not the raw list: terminal.list omits a chat-covered handle while the desktop - // graph reloads, so sweeping drafts against it erases text the user still holds. - expect(slice).not.toContain('pruneDrafts(liveHandles)') - expect(slice.match(/pruneDrafts\(/g)).toHaveLength(1) - expect(bufferedDraftHookSource).toContain( - 'setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))' - ) - expect(bufferedDraftHookSource).toContain( - 'pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)' - ) - }) - - it('leaves the accessory shortcut keys alone, Enter included', () => { - // Why: the accessory bar sits on top of the keyboard — dismissing would - // pull away the very row the user is tapping. - const slice = sourceSlice( - sendActionsSource, - 'async function handleAccessoryKey(', - 'const sendLiveTerminalInput = useCallback(' - ) - expect(slice).not.toContain('dismissKeyboardAfterAgentSend') - }) -}) diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts index 05e05b1f188..e9049b00cc1 100644 --- a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -3,17 +3,10 @@ import { mobileNativeChatQuestionOffsets, scheduleMobileClaudeAnswer, MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, - MOBILE_NATIVE_CHAT_QUESTION_STEP_MS, MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS } from './mobile-native-chat-answer-stepping' describe('mobileNativeChatQuestionOffsets', () => { - it('matches the desktop cadence constants', () => { - expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) - expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) - expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) - }) - it('paces each question a full step apart, Enter 500ms after its body', () => { expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 30bf33d7d51..3e037180530 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -10,7 +10,9 @@ import type { NativeChatLiveTurnIndicator, NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' @@ -37,10 +39,8 @@ export type MobileNativeChatController = { /** Structured lane: host-recorded turn timing for the per-turn status rows. */ nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null - /** Structured lane: the user message the host says opened the running turn. */ - nativeChatActiveTurnOpenedBy: string | null - /** Structured lane: the turn that owns each journal item's row. */ - nativeChatTurnKeysByItemId: ReadonlyMap | null + /** Structured lane: the journal that places each transcript row in its turn. */ + nativeChatTurnJournal: NativeChatTurnJournal | null nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ @@ -67,6 +67,9 @@ export type MobileNativeChatController = { }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void + /** Host-held queued drafts shown as cards above the composer (structured lane, + * capable host only; empty otherwise). */ + nativeChatQueued: MobileStructuredQueuedMessageControls nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void handleNativeChatQuestionAnswer: (text: string) => Promise diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 6c2c8c0286d..3ae08af6688 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -16,6 +16,8 @@ export type UnconfirmedSend = { text: string normalizedText: string baselineTailMessageId: string | null + /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ + baselineQueuedMessageIds?: readonly string[] deadline: ReturnType | null } @@ -219,6 +221,33 @@ export function findLandedImagePreviewEchoes( return landed } +/** Holds the host already took as queued-draft cards: a card not on screen at + * send time with the send's text. It will reach the transcript only when it + * drains, which can be long past the unconfirmed deadline. */ +export function findQueuedUnconfirmedSends( + cards: readonly { messageId: string; text: string }[], + entries: readonly UnconfirmedSend[] +): UnconfirmedSend[] { + if (cards.length === 0) { + return [] + } + const claimed = new Set() + const held: UnconfirmedSend[] = [] + for (const entry of entries) { + const card = cards.find( + (candidate) => + !claimed.has(candidate.messageId) && + !(entry.baselineQueuedMessageIds ?? []).includes(candidate.messageId) && + normalizeNativeChatUserText(candidate.text) === entry.normalizedText + ) + if (card) { + claimed.add(card.messageId) + held.push(entry) + } + } + return held +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-list-footer.tsx b/mobile/src/session/mobile-native-chat-list-footer.tsx new file mode 100644 index 00000000000..df81930e5fb --- /dev/null +++ b/mobile/src/session/mobile-native-chat-list-footer.tsx @@ -0,0 +1,23 @@ +import { Fragment, type ReactElement } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +type MobileNativeChatListRow = { item: NativeChatMessage; index: number } + +/** The live turn's status, then the messages waiting behind that turn, drawn below it. */ +export function mobileNativeChatListFooter( + liveStatus: ReactElement | null, + waitingRows: readonly MobileNativeChatListRow[], + renderRow: (row: MobileNativeChatListRow) => ReactElement +): ReactElement | null { + if (waitingRows.length === 0) { + return liveStatus + } + return ( + <> + {liveStatus} + {waitingRows.map((row) => ( + {renderRow(row)} + ))} + + ) +} diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 88e97018521..51ff9ab1f36 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -32,17 +32,6 @@ export const styles = StyleSheet.create({ reasoning: { opacity: 0.7 }, - // A subagent's row is an aside to the conversation, set off the way desktop sets it off. - subagent: { - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.md - }, - subagentCaption: { - color: colors.textMuted, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE - }, toolRun: { marginTop: spacing.xs }, diff --git a/mobile/src/session/mobile-native-chat-message-text.test.ts b/mobile/src/session/mobile-native-chat-message-text.test.ts deleted file mode 100644 index 165313c2e43..00000000000 --- a/mobile/src/session/mobile-native-chat-message-text.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { clampFontScale, FONT_SCALE_MAX, FONT_SCALE_MIN } from './mobile-native-chat-message-text' - -describe('clampFontScale', () => { - it('clamps below the minimum', () => { - expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN) - }) - - it('clamps above the maximum', () => { - expect(clampFontScale(5)).toBe(FONT_SCALE_MAX) - }) - - it('passes through an in-range value', () => { - expect(clampFontScale(1.2)).toBe(1.2) - }) - - it('falls back to 1 for NaN', () => { - expect(clampFontScale(Number.NaN)).toBe(1) - }) -}) diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 84d2f8f1cc9..22f09f322a9 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -1,9 +1,9 @@ /** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */ -export const FONT_SCALE_MIN = 0.8 -export const FONT_SCALE_MAX = 1.8 +const FONT_SCALE_MIN = 0.8 +const FONT_SCALE_MAX = 1.8 /** Clamp a proposed font scale into the supported range. */ -export function clampFontScale(scale: number): number { +function clampFontScale(scale: number): number { if (Number.isNaN(scale)) { return 1 } diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts index 0369a609135..dcda3f3dace 100644 --- a/mobile/src/session/mobile-native-chat-noise.test.ts +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -44,6 +44,7 @@ describe('isNoiseMessage', () => { it('keeps assistant and tool turns', () => { expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + expect(isNoiseMessage(msg('system', 'Conversation interrupted'))).toBe(false) }) it('keeps a user turn that carries tool results', () => { diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 91bf778ba7c..13604344789 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -22,6 +22,9 @@ export type MobileNativeChatSendOrigin = { baselineOccurrences: number baselineTailMessageId: string | null baselineResolved: boolean + /** Queued-draft cards already on screen at send time, so an earlier identical + * card cannot confirm this send. Structured lane on a queue-capable host only. */ + baselineQueuedMessageIds?: readonly string[] } type PendingByKey = Record diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 3c0281e24b8..58c1b961f83 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -430,3 +430,32 @@ describe('buildMobileNativeChatTransientData anchoring', () => { expect(data.map((message) => message.id)).toEqual(['a1', 'prompt', 'p1', 'a2']) }) }) + +describe("mobile shows the conversation, never a subagent's rows", () => { + it("keeps the spawn's one line and drops what the subagent said and did", () => { + const child = { agentId: 'task-1', producerKind: 'agent' as const } + const folded = foldMobileNativeChatMessages([ + user('ask', 'review the PR'), + { + id: 'spawn', + role: 'system', + blocks: [{ type: 'text', text: 'Kicked off 1 subagent' }], + timestamp: 0, + source: 'transcript' + }, + { ...assistant('child-said', 'The PR is CLEAN.'), ...child }, + { + id: 'child-grep', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Grep', input: {} }], + timestamp: 0, + source: 'transcript', + ...child + }, + assistant('answer', 'Delegated; nothing to fix.') + ]) + + expect(folded.map((message) => message.id)).toEqual(['ask', 'spawn', 'answer']) + expect(folded[2]?.blocks).toEqual([{ type: 'text', text: 'Delegated; nothing to fix.' }]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index da93efadbb8..40d5fa4a419 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,6 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' +import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer' import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' @@ -52,9 +53,13 @@ export type MobileNativeChatPendingItem = { } export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { + // The conversation only: a subagent's rows are that subagent's, and mobile shows + // each spawn as its roster's one line rather than the child's own rows. // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. - return stripNoiseMessages(foldToolMessages(normalizeImageTranscriptMessages(messages))) + return stripNoiseMessages( + foldToolMessages(normalizeImageTranscriptMessages(messages.filter(isRootAgentJournalItem))) + ) } /** Assemble the folded transcript, streaming text, and optimistic user echoes. */ diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 7bc1b94f993..2a3bc5a60b2 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -28,8 +28,13 @@ type MobileNativeChatSendArgs = { /** 'unknown' = the RPC failed without proof the request never reached the * desktop (ack loss after a write, or a cutover that cannot tell whether the - * frame was written) — callers must not present it as a definite send failure. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' + * frame was written) — callers must not present it as a definite send failure. + * 'queued' = structured lane only: the host holds the message as a queued + * draft, so it shows as a card above the composer, never a transcript echo. */ +export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued' + +/** What a terminal write can answer: the PTY lane has no draft queue. */ +export type MobileNativeChatWriteOutcome = Exclude /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -46,7 +51,7 @@ export function openMobileNativeChatSendBudget(): number { export async function sendMobileNativeChatMessageWithOutcome( args: MobileNativeChatSendArgs -): Promise { +): Promise { const timeoutMs = args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() // Starting an underfunded final write risks delivery followed by a false timeout. @@ -98,7 +103,7 @@ export async function typeMobileNativeChatCommandWithOutcome(args: { resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient deadline?: number -}): Promise { +}): Promise { let writeIndex = 0 return typeAgentTuiCommand({ command: args.command, diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index e7733dac1ce..2112d1d9b96 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -350,7 +350,7 @@ describe('applyMobileNativeChatStreamFrame', () => { }) }) - it('surfaces snapshot errors and ignores unrelated frames', () => { + it('surfaces snapshot errors, reports a host end as an error, and ignores unrelated frames', () => { const merger = createNativeChatMerger() expect( applyMobileNativeChatStreamFrame({ @@ -375,6 +375,14 @@ describe('applyMobileNativeChatStreamFrame', () => { limit: 40, replaceSnapshot: true }) + ).toEqual({ kind: 'error', error: 'Transcript stream ended' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'subscribed' }, + limit: 40, + replaceSnapshot: true + }) ).toEqual({ kind: 'ignored' }) }) }) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index 8984a04a0e3..96d74da3a6b 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -78,6 +78,10 @@ export function applyMobileNativeChatStreamFrame(args: { if (frame.type === 'error') { return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' } } + // Why: a feed's token is its own, so an end that reaches here was not asked for; the feed is dead. + if (frame.type === 'end') { + return { kind: 'error', error: 'Transcript stream ended' } + } if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') { return { kind: 'ignored' } } diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts deleted file mode 100644 index a15a62b1535..00000000000 --- a/mobile/src/session/mobile-session-last-tab-close.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSourceFamily } from './mobile-session-route-source-family.test-support' - -const sessionRouteSource = readMobileSessionRouteSourceFamily() - -describe('mobile session last-tab close', () => { - it('preserves terminal identity while an empty snapshot may be transient', () => { - const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') - const end = sessionRouteSource.indexOf('const readMarkdownTab', start) - const block = sessionRouteSource.slice(start, end) - - expect(block).toContain('} else if (active) {') - expect(block).toContain('retainMissingSurfaces: result.tabs.length === 0') - }) - - it('clears stale active identity when closing leaves no tabs', () => { - const start = sessionRouteSource.indexOf('async function handleCloseSessionTab') - const end = sessionRouteSource.indexOf('const bulkCloseActions', start) - const block = sessionRouteSource.slice(start, end) - - expect(block).toContain( - 'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0' - ) - expect(block).toContain('activeSessionTabIdRef.current = null') - expect(block).toContain('activeHandleRef.current = null') - expect(block).toContain( - 'reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)' - ) - }) - - it('ignores a pending terminal handle when the host requests follow navigation', () => { - const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') - const end = sessionRouteSource.indexOf('const readMarkdownTab', start) - const block = sessionRouteSource.slice(start, end) - - const followsHost = block.indexOf("const followsHost = result.navigationIntent === 'follow'") - const pendingHandle = block.indexOf('const pendingActiveTerminalHandle = followsHost') - - expect(followsHost).toBeGreaterThanOrEqual(0) - expect(pendingHandle).toBeGreaterThan(followsHost) - expect(block.slice(pendingHandle, pendingHandle + 150)).toContain('? null') - }) -}) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts deleted file mode 100644 index 7c0ac36e453..00000000000 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' -import { MOBILE_SESSION_ROUTE_SOURCE_FILES } from './mobile-session-route-source-family.test-support' - -const SESSION_FILES = MOBILE_SESSION_ROUTE_SOURCE_FILES -/** The function the route mounts, which C7.7 moved out of the route file and into a component. */ -const ROOT_COMPONENT = 'MobileSessionRouteScreen' -const LOGIC_EXPANSION_NAMES = new Set([ - 'useMobileSessionController', - 'useMobileSessionFoundation', - 'useMobileSessionScreenState', - 'useMobileSessionTerminalRuntime', - 'useMobileSessionFeedbackCapabilities', - 'useMobileSessionNativeChatDictation', - 'useMobileSessionTerminalSubscriptionFoundation', - 'useMobileSessionTerminalSubscription', - 'useMobileSessionTerminalStreamDisplay', - 'useMobileSessionTerminalList', - 'useMobileSessionTabApplication', - 'useMobileSessionDocumentReaders', - 'useMobileSessionDiffComments', - 'useMobileSessionMarkdownActions', - 'useMobileSessionTabReconciliation', - 'useMobileSessionLifecycle', - 'useMobileSessionKeyboardState', - 'useMobileSessionStartup', - 'useMobileSessionPreferenceFocus', - 'useMobileSessionTabSwitching', - 'useMobileSessionTerminalWebview', - 'useMobileSessionTerminalSendActions', - 'useMobileSessionFileActions', - 'useMobileSessionTerminalInput', - 'useMobileSessionAccessorySelection', - 'useMobileSessionAttachments', - 'useMobileSessionTerminalCreateActions', - 'useMobileSessionContentCreateActions', - 'useMobileSessionCloseActions', - 'useMobileSessionBulkClose', - 'useMobileSessionPresentation', - 'useMobileSessionPanelRouteActions' -]) -const SURFACE_EXPANSION_NAMES = new Set([ - 'MobileSessionSurface', - 'MobileSessionHeader', - 'MobileSessionContentRow', - 'MobileSessionActiveContent', - 'MobileSessionCommandDock', - 'MobileSessionSheets' -]) -const CONTENT_COMPONENT_NAMES = ['MarkdownReader', 'DiffLineRow', 'FileReader'] as const -const HOST_COMPONENT_NAMES = new Set([ - 'ActivityIndicator', - 'Animated.View', - 'FlatList', - 'Image', - 'Pressable', - 'SafeAreaView', - 'ScrollView', - 'Text', - 'TextInput', - 'View' -]) - -// Refreshed by C7.2: five clipboard hooks joined the expanded route, which is the whole of the +5 — -// a writer in the diff-note, Markdown and selection actions, a reader in the selection actions and -// the attachment probe. The screen's other four clipboard sites (the terminal's paste, the sheets, -// the quick-command row, the diff-review send) sit outside the walk from `MobileSessionRouteScreen` -// and so do not move this pin. The copy-path sheet also gained the failure toast the other two had. -// Refreshed for `reportDictationFailure`, the whole of that +1: the composer's two dictation -// failure handlers were one policy written twice, and only `onError`'s copy knew about the setup -// sheet, so a refused start showed the desktop's own `voice_dictation_disabled` as a toast. -// -// Refreshed again for the page's keyboard: the screen's own `Keyboard.addListener` pair became the -// `useSoftKeyboard` seam, because react-native-web never fires those events and the live input row -// laid itself out under the IME. +2 hooks (the seam, and the one effect split into a visibility one -// and a height one), +1 effect, -2 registrations and -2 removals for the listener pair, and -6 -// strings: the four event names and the two `'ios'` guards that chose between them. Re-recorded -// against the merged tree, since neither side's hash covers the other's change. -// -// Refreshed for the terminal fields' submit seam: react-native-web withholds `onSubmitEditing` -// when the Enter keydown reports an open composition, which is a soft keyboard's normal mid-word -// state, so both of the dock's fields also bind the page's own line-break signal. +4 hooks and +2 -// callbacks in the send actions — `submitLiveInput` and `submitBufferedDraft`, each with a -// `useTerminalTextFieldSubmitBinding` beside it — and both fields moved their ref, the live one -// its submit handler too. -// Moved once more, -1 hook and -1 callback: `submitBufferedDraft` stopped being a `useCallback`. -// Memoizing it on `[]` froze the per-render `handleSend` it reads, whose guard needs a `client` -// and an `activeHandle` the first render does not have, so the page's line-break submit could -// never pass it. Found by pullfrog on #22300. -// Moved, count unchanged, when the Markdown actions' Back `useEffect` became `useBackClaim`, the -// seam that also claims the key on the page while a draft is dirty. -// Refreshed when the first subscribe began sizing itself from the document's reported cell box: -// the first-subscribe mark ref and `measureViewportOnce` are gone (hooks 282 → 280, callbacks 79 → 78). -// Again when xterm became the only cell measurer: `useTerminalCellBoxRefit` is gone, the frame's -// width ref and `handleTerminalFrameLayout` subscribe a known box on layout (hooks 282, callbacks 79), -// and the pane's `onCellBoxChange` goes to the viewport refit. -// Again when one frame ref replaced the height ref, width ref and width state (hooks 280). -// Again when one `notifyTerminalFrame` took the frame's layout (hooks 281, callbacks 80). -const HEAD_MAIN_HOOK_SHA256 = '004b011722b17ac82c96f0b3c8e303d39b2431a216424e9b86a1ee6a4896f23e' -// Moved when the prompt-cancel flag became one structured-session host support object (main). -// Re-recorded against the merged tree. Again when the frame-layout and cell-box-change callbacks -// named the refs they read in their dependency lists (react-doctor). -// Again when the frame's width and height became one `terminalFrameRef`. -// Again when the frame's layout moved into `notifyTerminalFrame`. -const HEAD_HOOK_BINDING_SHA256 = 'c1bcb859202aaf8d612d3023cb4d895719da513879c61b01545a249a0bda662b' -// Moved when `notifyTerminalFrame` joined and `handleTerminalFrameLayout` became `subscribeIntendedActiveTerminal`. -const HEAD_CALLBACK_IDENTITY_SHA256 = - '9c966301b373c11b27359ef1388c593b638c7f6831186d5c5321553504183e07' -// Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends -// and repo reads inside them now name their `RpcOperation` instead of the raw `sendRequest` port. -// Refreshed in step 6 for the gesture flush, whose `terminal.send` became `terminalInputSend` and -// whose accepted-check became that operation's own verdict, then again when that check was spelled -// `=== true` to match the other four sites reading the same verdict. Refreshed in step 7 for the -// reply casts the checked readers made unnecessary — the markdown tab doc, the worktree record's -// `diffComments` and the browser tab's page id are typed by their schemas now. Refreshed once more -// on the merge, for the display-mode toggle whose send became `terminalDisplayModeSet`. Refreshed -// for the files domain's step 7, which retired the markdown disk fallback's `{ content, truncated, -// byteLength }` cast: the preview reader checks the content and salvages the flag, so `readMarkdownTab` -// reads `fallback.value` directly. The dictation-mode refresh is main's own body again — it forwards -// whatever mode the reply carried, so an absent one leaves the mic as inert as main left it. -// Refreshed on the merge of C7.2 and C7.3, which moved this pin from both sides: the terminal -// subscribe now carries the snapshot byte budget its transport imposes, nothing on a phone and the -// frame cap inside the shell's page, and the Markdown copy action gained the failure branch that -// answers a refused write. Re-recorded against the merged tree, since neither side's hash covers -// the other's body. The hook and string counts are C7.2's and stand. -// Refreshed once more for the two dictation failure handlers, which now both call -// `reportDictationFailure` instead of each choosing between the setup sheet and a toast. -// Moved when the metrics handler compared every field, the row pitch included (main). -// Again when the subscribe waited for web-ready and `handleTerminalWebReady` lost `documentHasInit`. -// Again when the subscribe's inits began carrying the laid-out terminal frame, and its resize too. -// Again when those grids read the one frame ref. -// Again when the subscribe sized its viewport inline instead of through a helper. -// Again when the document took the hold rule and the subscribe stopped holding its grid. -// Again when an init took one options object. -// Again when the frame's layout became one `notifyTerminalFrame`. -// Again when the init option took the message's name, `initialData`. -// Again when the document readers mapped refusal codes through one function and kept truncation. -const HEAD_CALLBACK_BODY_SHA256 = 'ff818790399c8532ead38d047d072caf715070b21311538932da2dc811312b06' -// Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and -// the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed -// again when the reporter took the reply and interpreted it itself, retiring the hand-built -// refusal the timer site passed when it had no reply at all. Refreshed once more for the -// last-visited-worktree effect, whose bare store write became the one writer of that key, so the -// hybrid shell's page mirror sees it as it is written rather than one `init` later. Refreshed for -// the diff-comments effect, which now catches the loader's rejection. Count unchanged. -// Moved again by the keyboard seam above, which is the +1 effect. -// -1 effect for the Markdown actions' Back registration, which is `useBackClaim`'s own now. -// Moved by the capability probe setting that host support object. -const HEAD_EFFECT_SHA256 = '9b045a547ed269acf95db16cc87e33a9035a20c6888fd30e0363e58bb6b7d883' -const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' -// Same pin for the 12 bodies that sit in nested functions rather than callbacks, moved by the same -// rewrite of those send and read expressions. Count unchanged. Refreshed again in step 6 for -// `handleClearTerminal`, whose send became `terminalBufferClear`, in step 7 for the browser tab -// create, whose `{ browserPageId?: string }` cast its schema now carries, and once more for -// `handleCreateTerminal`, whose send became `sessionTabCreateTerminal` and whose `response.ok` -// branch became that operation's own throw-the-host-message acceptance. Refreshed for negotiated -// optimistic placement, which defers to legacy host snapshots when ownership paths disagree. -const HEAD_NESTED_FUNCTION_SHA256 = - '923b5ea7fe3330cbd98213b72736bf1f653115ddb5492cb8eb8306d8ca4f28e8' -// -1 registration and -1 removal: the Markdown actions' `BackHandler` pair is `useBackClaim`'s. -const HEAD_NATIVE_REGISTRATION_SHA256 = - '87d4599f475575131d4d5daa20f0dac579ca6c829353cbb654206ea6965dadae' -const HEAD_NATIVE_REMOVAL_SHA256 = - '089bebbc1e4f2e68c5dbaf189bb605d8d5bb247ef5ca52b9db08cc2661afc097' -const HEAD_TIMER_CREATION_SHA256 = - '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' -const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' -// Six method literals fewer than before step 6: `terminal.send` and `terminal.clearBuffer` went -// first, then `worktree.activate` twice, `session.tabs.createTerminal` and -// `terminal.setDisplayMode`. Each is now fixed at its operation's definition instead of being -// spelled at the call site. Two literals more across C7.2, both of them the toast a refused write -// now shows: "Couldn't copy path" when the sheets moved onto the clipboard seam, taking the count -// from 532 to 533, and "Couldn't copy" when the Markdown copy action gained the failure branch the -// other copy paths already had, taking it to 534. -// -// Two more across C7.7, 534 -> 536: `'web'`, the platform guard the Markdown actions' -// `BackHandler` registration gained so it stops logging on the page, and `"button"`, the -// accessibility role the header's Back control gained so the page serves it by name. Neither is a -// behaviour change on a phone. The same `'web'` moves the effect hash, and `"button"` the host-JSX -// hash. The effect hash moved a second time and back: the diff-notes loader's uncaught rejection -// was caught and then reverted, because the fix moves `matrix-session.diff-notes-worktree.show-1` -// and a golden is a review event — so this hash is the one the uncaught `void` call produces. -// -// 536 -> 530 for the keyboard seam above: the four event names and the two `'ios'` guards left -// with the listener pair. -// -// The host-JSX hash moved for the header's reconnect tap, which now also checks the re-dial exists: -// the page's provider hands out none. Nothing a phone renders or does changed. -// -// 530 -> 529: `'hardwareBackPress'` left with the Markdown actions' registration for `useBackClaim`. -// -// 529 -> 530, and the host-JSX hash: `key="terminal-frame"`, so the page's frame mounts with its -// onLayout rather than reusing the loading View. Native measured 47 rows before and after: its -// frame reported either way, and its window is its frame, so both measure paths agree there. -// -// 531 -> 530, and the host-JSX and style hashes: the key left, and one `contentFrame` View wraps -// every branch and carries the frame's onLayout, so the page's frame mounts with it. The page measured -// 47 rows before and after; native was measured only on main's bundle (47), and the wrapper is a -// flex:1 View around the same flex:1 frame, so its box is the frame's. -// Count unchanged: `'frame-not-laid-out'`, the gate awaiting the frame's first layout, replaced `'measuring-viewport'`. -// 530 -> 532, and the host-JSX hash: the tab bar and the accessory bar take a ref that gives the -// page `keyboardShouldPersistTaps` ('handled', 'always'), which react-native-web ignores. Natively -// the ref is undefined. 532 -> 531: the live input's reopen flag reads the host OS, not an 'android' literal. -// 531 -> 529: the markdown status line moved to `markdownReaderStatusText`. -const HEAD_RUNTIME_STRING_SHA256 = - '4ab2f316f60c234480615136c02273675543f24d653eb76a62b76f6bc986d985' -// Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now; -// their keyboard type and remount key read the host OS. -// Moved again when the terminal frame kept its laid-out width unrounded, for every fit. -// Again when the frame's onLayout wrote one frame ref and notified a new width imperatively. -// Again when the frame's first laid-out layout alone subscribes a held-back document. -// Again when the frame's onLayout made one `notifyTerminalFrame` call. -const HEAD_HOST_JSX_SHA256 = 'f71b1ee495f67a6e389abf597948ffdff1f9a4a10cde6abefd2611ea4dd94245' -const HEAD_LEAF_JSX_SHA256 = '62eb05c6e2ac0be6d553a141fc8aa1641fcb0c678777d5d539f490aab8648417' -const HEAD_STYLE_REFERENCE_SHA256 = - '56a005a1f65b30c11092e3422caef67810e1ec50f66fdd06471c370138b1eeb6' -const HEAD_IDENTITY_FIELD_SHA256 = - '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' -const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' -// Moved when structured-session features became one helper call; the capability strings it reads -// are pinned by that helper's own test. -const HEAD_CAPABILITY_SHA256 = 'ec1159d6e726383bf7121e9c642657fee5b4ebec049cb44ba132a303bb9a61e2' - -type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } -type HookFacts = { - bindings: string[] - callbackBodies: string[] - callbacks: string[] - effects: string[] - hooks: string[] -} - -const printer = ts.createPrinter({ removeComments: true }) -const sourceFiles = new Map() - -function parse(relativePath: string): ts.SourceFile { - const cached = sourceFiles.get(relativePath) - if (cached) { - return cached - } - const filePath = fileURLToPath(new URL(relativePath, import.meta.url)) - const sourceFile = ts.createSourceFile( - relativePath, - readFileSync(filePath, 'utf8'), - ts.ScriptTarget.Latest, - true, - relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - sourceFiles.set(relativePath, sourceFile) - return sourceFile -} - -function canonical(node: ts.Node, sourceFile: ts.SourceFile): string { - return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile).replace(/\s+/g, '') -} - -function hash(values: readonly string[]): string { - return createHash('sha256').update(values.join('\n')).digest('hex') -} - -function readDefinitions(): Map { - const definitions = new Map() - for (const relativePath of SESSION_FILES) { - const sourceFile = parse(relativePath) - const visit = (node: ts.Node): void => { - if (ts.isFunctionDeclaration(node) && node.name) { - definitions.set(node.name.text, { declaration: node, sourceFile }) - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - } - return definitions -} - -function visitLogicalFunction( - name: string, - definitions: ReadonlyMap, - onNode: (node: ts.Node, sourceFile: ts.SourceFile) => void, - active = new Set() -): void { - const definition = definitions.get(name) - if (!definition?.declaration.body) { - throw new Error(`Missing session function: ${name}`) - } - if (active.has(name)) { - throw new Error(`Recursive session function: ${name}`) - } - const nextActive = new Set(active).add(name) - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - LOGIC_EXPANSION_NAMES.has(node.expression.text) - ) { - visitLogicalFunction(node.expression.text, definitions, onNode, nextActive) - return - } - onNode(node, definition.sourceFile) - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) -} - -function readHookFacts(name: string, definitions: ReadonlyMap): HookFacts { - const facts: HookFacts = { - bindings: [], - callbackBodies: [], - callbacks: [], - effects: [], - hooks: [] - } - visitLogicalFunction(name, definitions, (node, sourceFile) => { - if ( - !ts.isCallExpression(node) || - !ts.isIdentifier(node.expression) || - !/^use[A-Z]/.test(node.expression.text) - ) { - return - } - const hookName = node.expression.text - facts.hooks.push(hookName) - const owner = ts.isVariableDeclaration(node.parent) - ? node.parent.name.getText(sourceFile) - : ts.isExpressionStatement(node.parent) - ? '' - : ts.isCallExpression(node.parent) && ts.isIdentifier(node.parent.expression) - ? `` - : '' - const lastArgument = node.arguments.at(-1) - const dependencies = - lastArgument && ts.isArrayLiteralExpression(lastArgument) - ? canonical(lastArgument, sourceFile) - : '' - facts.bindings.push(`${hookName}|${owner}|${dependencies}`) - if (hookName === 'useCallback') { - facts.callbacks.push(`${owner}|${dependencies}`) - facts.callbackBodies.push( - `${owner}|${canonical(node.arguments[0], sourceFile)}|${dependencies}` - ) - } - if (hookName === 'useEffect') { - facts.effects.push(`${canonical(node.arguments[0], sourceFile)}|${dependencies}`) - } - }) - return facts -} - -function readNestedFunctions(definitions: ReadonlyMap): string[] { - const functions: string[] = [] - const visitDefinition = (name: string, active: ReadonlySet): void => { - const definition = definitions.get(name) - if (!definition?.declaration.body || active.has(name)) { - throw new Error(`Invalid nested-function stage: ${name}`) - } - const nextActive = new Set(active).add(name) - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - LOGIC_EXPANSION_NAMES.has(node.expression.text) - ) { - visitDefinition(node.expression.text, nextActive) - return - } - if (ts.isFunctionDeclaration(node) && node.name) { - functions.push(`${node.name.text}|${canonical(node, definition.sourceFile)}`) - return - } - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) - } - visitDefinition(ROOT_COMPONENT, new Set()) - return functions -} - -function readNativeAndTimerFacts(definitions: ReadonlyMap): { - cleanups: string[] - creations: string[] - registrations: string[] - removals: string[] -} { - const registrations: string[] = [] - const removals: string[] = [] - const creations: string[] = [] - const cleanups: string[] = [] - const collect = (node: ts.Node, sourceFile: ts.SourceFile): void => { - if (!ts.isCallExpression(node)) { - return - } - if (ts.isPropertyAccessExpression(node.expression)) { - const receiver = node.expression.expression.getText(sourceFile) - const method = node.expression.name.text - if ( - ['BackHandler', 'AppState', 'Keyboard'].includes(receiver) && - ['addEventListener', 'addListener'].includes(method) - ) { - registrations.push(canonical(node, sourceFile)) - } - if (method === 'remove') { - removals.push(canonical(node, sourceFile)) - } - } - if (ts.isIdentifier(node.expression)) { - if (['setTimeout', 'setInterval', 'requestAnimationFrame'].includes(node.expression.text)) { - creations.push(canonical(node, sourceFile)) - } - if ( - ['clearTimeout', 'clearInterval', 'cancelAnimationFrame'].includes(node.expression.text) - ) { - cleanups.push(canonical(node, sourceFile)) - } - } - } - visitLogicalFunction('FileReader', definitions, collect) - visitLogicalFunction(ROOT_COMPONENT, definitions, collect) - return { cleanups, creations, registrations, removals } -} - -function isRuntimeNode(node: ts.Node): boolean { - for (let parent = node.parent; parent; parent = parent.parent) { - if ( - ts.isImportDeclaration(parent) || - ts.isExportDeclaration(parent) || - ts.isImportTypeNode(parent) || - ts.isTypeNode(parent) - ) { - return false - } - } - return true -} - -function readRuntimeStrings(): string[] { - const values: string[] = [] - for (const relativePath of SESSION_FILES) { - const visit = (node: ts.Node): void => { - if (isRuntimeNode(node)) { - if ( - ts.isStringLiteral(node) || - ts.isNoSubstitutionTemplateLiteral(node) || - ts.isTemplateHead(node) || - ts.isTemplateMiddle(node) || - ts.isTemplateTail(node) - ) { - values.push(node.text) - } - if (ts.isJsxText(node) && node.text.trim()) { - values.push(node.text.replace(/\s+/g, ' ').trim()) - } - } - ts.forEachChild(node, visit) - } - visit(parse(relativePath)) - } - return values.sort() -} - -function readJsxFacts(definitions: ReadonlyMap): { - host: string[] - leaf: string[] - styleReferences: string[] -} { - const host: string[] = [] - const leaf: string[] = [] - const active = new Set() - const visitDefinition = (name: string): void => { - const definition = definitions.get(name) - if (!definition?.declaration.body || active.has(name)) { - throw new Error(`Invalid JSX stage: ${name}`) - } - active.add(name) - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - LOGIC_EXPANSION_NAMES.has(node.expression.text) - ) { - visitDefinition(node.expression.text) - return - } - if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { - const opening = ts.isJsxElement(node) ? node.openingElement : node - const tagName = opening.tagName.getText(definition.sourceFile) - if (SURFACE_EXPANSION_NAMES.has(tagName)) { - visitDefinition(tagName) - return - } - const attributes = opening.attributes.properties - .map((attribute) => { - if (ts.isJsxSpreadAttribute(attribute)) { - return `...${canonical(attribute.expression, definition.sourceFile)}` - } - const attributeName = attribute.name.getText(definition.sourceFile) - if (!attribute.initializer) { - return attributeName - } - if (ts.isStringLiteral(attribute.initializer)) { - return `${attributeName}=${JSON.stringify(attribute.initializer.text)}` - } - return `${attributeName}=${ - attribute.initializer.expression - ? canonical(attribute.initializer.expression, definition.sourceFile) - : '' - }` - }) - .join(',') - ;(HOST_COMPONENT_NAMES.has(tagName) ? host : leaf).push(`${tagName}|${attributes}`) - for (const attribute of opening.attributes.properties) { - ts.forEachChild(attribute, visit) - } - if (ts.isJsxElement(node)) { - for (const child of node.children) { - visit(child) - } - } - return - } - if (ts.isJsxFragment(node)) { - for (const child of node.children) { - visit(child) - } - return - } - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) - active.delete(name) - } - for (const name of CONTENT_COMPONENT_NAMES) { - visitDefinition(name) - } - visitDefinition(ROOT_COMPONENT) - const styleReferences: string[] = [] - for (const record of [...host, ...leaf]) { - for (const match of record.matchAll(/styles\.([A-Za-z0-9_]+)/g)) { - styleReferences.push(match[1]) - } - } - return { host, leaf, styleReferences } -} - -function readCompatibilityFacts(definitions: ReadonlyMap): { - capabilities: string[] - identityFields: string[] - navigation: string[] -} { - const capabilities: string[] = [] - const identityFields: string[] = [] - const navigation: string[] = [] - visitLogicalFunction(ROOT_COMPONENT, definitions, (node, sourceFile) => { - if (!isRuntimeNode(node)) { - return - } - if (ts.isPropertyAssignment(node)) { - const name = node.name.getText(sourceFile) - if (['notifyClients', 'deviceToken', 'clientId'].includes(name)) { - identityFields.push(`${name}|${canonical(node.initializer, sourceFile)}`) - } - if ( - name === 'client' && - ts.isObjectLiteralExpression(node.initializer) && - node.initializer.properties.some( - (property) => property.name?.getText(sourceFile) === 'id' - ) && - node.initializer.properties.some( - (property) => property.name?.getText(sourceFile) === 'type' - ) - ) { - identityFields.push(`client|${canonical(node.initializer, sourceFile)}`) - } - } - if (!ts.isCallExpression(node)) { - return - } - if ( - ts.isPropertyAccessExpression(node.expression) && - node.expression.expression.getText(sourceFile) === 'router' && - ['push', 'replace', 'back'].includes(node.expression.name.text) - ) { - navigation.push(canonical(node, sourceFile)) - } - const callName = ts.isIdentifier(node.expression) - ? node.expression.text - : ts.isPropertyAccessExpression(node.expression) - ? node.expression.name.text - : '' - const callText = canonical(node, sourceFile) - if ( - [ - 'startRuntimeCapabilityProbe', - 'supportsMobileQuickCommands', - 'structuredAgentSessionHostSupport' - ].includes(callName) || - (callName === 'includes' && callText.includes('capabilities.includes')) - ) { - capabilities.push(callText) - } - }) - return { capabilities, identityFields, navigation } -} - -describe('mobile session route extraction parity', () => { - it('preserves hooks, callbacks, effects, and nested action bodies', () => { - const definitions = readDefinitions() - const main = readHookFacts(ROOT_COMPONENT, definitions) - const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( - (name) => readHookFacts(name, definitions).bindings - ) - expect(main.hooks).toHaveLength(281) - expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) - expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) - expect(main.callbacks).toHaveLength(80) - expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) - expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) - expect(main.effects).toHaveLength(24) - expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256) - expect(contentBindings).toHaveLength(14) - expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256) - const nestedFunctions = readNestedFunctions(definitions) - expect(nestedFunctions).toHaveLength(12) - expect(hash(nestedFunctions)).toBe(HEAD_NESTED_FUNCTION_SHA256) - }) - - it('preserves native listeners, timers, identity payloads, and compatibility gates', () => { - const definitions = readDefinitions() - const native = readNativeAndTimerFacts(definitions) - expect(native.registrations).toHaveLength(4) - expect(hash(native.registrations)).toBe(HEAD_NATIVE_REGISTRATION_SHA256) - expect(native.removals).toHaveLength(6) - expect(hash(native.removals)).toBe(HEAD_NATIVE_REMOVAL_SHA256) - expect(native.creations.filter((fact) => fact.startsWith('setTimeout'))).toHaveLength(7) - expect(native.creations.filter((fact) => fact.startsWith('setInterval'))).toHaveLength(1) - expect( - native.creations.filter((fact) => fact.startsWith('requestAnimationFrame')) - ).toHaveLength(1) - expect(hash(native.creations)).toBe(HEAD_TIMER_CREATION_SHA256) - expect(native.cleanups.filter((fact) => fact.startsWith('clearTimeout'))).toHaveLength(11) - expect(native.cleanups.filter((fact) => fact.startsWith('clearInterval'))).toHaveLength(1) - expect(native.cleanups.filter((fact) => fact.startsWith('cancelAnimationFrame'))).toHaveLength( - 1 - ) - expect(hash(native.cleanups)).toBe(HEAD_TIMER_CLEANUP_SHA256) - const compatibility = readCompatibilityFacts(definitions) - expect(compatibility.identityFields).toHaveLength(14) - expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256) - expect(compatibility.navigation).toHaveLength(6) - expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256) - expect(compatibility.capabilities).toHaveLength(6) - expect(hash(compatibility.capabilities)).toBe(HEAD_CAPABILITY_SHA256) - }) - - it('preserves runtime strings, styles, and the expanded JSX tree', () => { - const strings = readRuntimeStrings() - expect(strings).toHaveLength(529) - expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) - const jsx = readJsxFacts(readDefinitions()) - expect(jsx.host).toHaveLength(125) - expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) - expect(jsx.leaf).toHaveLength(61) - expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) - expect(jsx.styleReferences).toHaveLength(173) - expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256) - }) -}) diff --git a/mobile/src/session/mobile-session-route-source-family.test-support.ts b/mobile/src/session/mobile-session-route-source-family.test-support.ts deleted file mode 100644 index 23ae6fb348e..00000000000 --- a/mobile/src/session/mobile-session-route-source-family.test-support.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' - -/** - * The session screen as one source family, rooted at the component the route mounts. - * - * `MobileSessionRouteScreen.tsx` and not the route file: C7.7 made that file a flag switch, whose - * business is which of the two screens to render and not what the session screen does. Walking it - * would pin the switch's own params and literals into an extraction parity hash that is about the - * screen, and the root function this family is read from is the one that calls the controller. - */ -export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [ - './MobileSessionRouteScreen.tsx', - './use-mobile-session-controller.ts', - './use-mobile-session-foundation.ts', - './use-mobile-session-screen-state.ts', - './use-mobile-session-terminal-runtime.ts', - './use-mobile-session-feedback-capabilities.ts', - './use-mobile-session-native-chat-dictation.ts', - './use-mobile-session-terminal-subscription-foundation.ts', - './use-mobile-session-terminal-subscription.ts', - './use-mobile-session-terminal-stream-display.ts', - './use-mobile-session-terminal-list.ts', - './use-mobile-session-tab-application.ts', - './use-mobile-session-document-readers.ts', - './use-mobile-session-diff-comments.ts', - './use-mobile-session-markdown-actions.ts', - './use-mobile-session-tab-reconciliation.ts', - './use-mobile-session-lifecycle.ts', - './use-mobile-session-keyboard-state.ts', - './use-mobile-session-startup.ts', - './use-mobile-session-preference-focus.ts', - './use-mobile-session-tab-switching.ts', - './use-mobile-session-terminal-webview.ts', - './use-mobile-session-terminal-send-actions.ts', - './use-mobile-session-file-actions.ts', - './use-mobile-session-terminal-input.ts', - './use-mobile-session-accessory-selection.ts', - './use-mobile-session-attachments.ts', - './use-mobile-session-terminal-create-actions.ts', - './use-mobile-session-content-create-actions.ts', - './use-mobile-session-close-actions.ts', - './use-mobile-session-bulk-close.ts', - './use-mobile-session-presentation.ts', - './use-mobile-session-panel-route-actions.tsx', - './MobileSessionMarkdownReader.tsx', - './MobileSessionDiffLineRow.tsx', - './MobileSessionFileReader.tsx', - './MobileSessionSurface.tsx', - './MobileSessionHeader.tsx', - './MobileSessionContentRow.tsx', - './MobileSessionActiveContent.tsx', - './MobileSessionCommandDock.tsx', - './MobileSessionSheets.tsx' -] as const - -export function readMobileSessionRouteSource(relativePath: string): string { - return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8') -} - -export function readMobileSessionRouteSourceFamily( - relativePaths: readonly string[] = MOBILE_SESSION_ROUTE_SOURCE_FILES -): string { - return relativePaths - .map( - (relativePath) => `// Source: ${relativePath}\n${readMobileSessionRouteSource(relativePath)}` - ) - .join('\n') -} diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 33872e0271f..4cd040a061a 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { mobileSessionRouteTarget } from './mobile-session-route' import { @@ -7,8 +6,6 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') - function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() let state = initialState @@ -95,20 +92,4 @@ describe('mobile session route', () => { payload: target }) }) - - it('routes the home Resume card through the cold-navigator-safe transition', () => { - expect(homeSource).toContain('onOpenResume={openResume}') - - // The tap handler itself must go through the coordinated transition; its only - // direct push is the shallow noticed host-index route for a proven-missing target. - const handlerStart = homeSource.indexOf('const openResume = useCallback(') - const handlerEnd = homeSource.indexOf('[data.router, openMobileSession]', handlerStart) - expect(handlerStart).toBeGreaterThanOrEqual(0) - expect(handlerEnd).toBeGreaterThan(handlerStart) - - const openResume = homeSource.slice(handlerStart, handlerEnd) - expect(openResume).toContain('openMobileSession({') - expect(openResume.match(/router\.push\(/g)).toHaveLength(1) - expect(openResume).toContain('data.router.push(hostRouteWithNotice(') - }) }) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts deleted file mode 100644 index b457164beed..00000000000 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - readMobileSessionRouteSource, - readMobileSessionRouteSourceFamily -} from './mobile-session-route-source-family.test-support' - -const source = readMobileSessionRouteSourceFamily() -const startupSource = readMobileSessionRouteSource('./use-mobile-session-startup.ts') -const tabReconciliationSource = readMobileSessionRouteSource( - './use-mobile-session-tab-reconciliation.ts' -) -const bulkCloseSource = readMobileSessionRouteSource('./use-mobile-session-bulk-close.ts') -const presentationSource = readMobileSessionRouteSource('./use-mobile-session-presentation.ts') -const tabSwitchingSource = readMobileSessionRouteSource('./use-mobile-session-tab-switching.ts') -const sheetsSource = readMobileSessionRouteSource('./MobileSessionSheets.tsx') -const reconciliationHookSource = readMobileSessionRouteSource( - './use-mobile-session-tabs-reconciliation.ts' -) -const terminalInventoryRecoverySource = readMobileSessionRouteSource( - './use-mobile-terminal-inventory-recovery.ts' -) -const terminalSubscriptionSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-subscription.ts' -) -const terminalListSource = readMobileSessionRouteSource('./use-mobile-session-terminal-list.ts') -const tabReconciliationOwnerSource = readMobileSessionRouteSource( - './use-mobile-session-tab-reconciliation.ts' -) -const autoCreateHookSource = readMobileSessionRouteSource( - './use-initial-session-terminal-autocreate.ts' -) -const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts') -const terminalRuntimeSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-runtime.ts' -) -const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource( - './use-mobile-session-terminal-subscription.ts' -) -const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts') - -function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string { - const start = targetSource.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = targetSource.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return targetSource.slice(start, end) -} - -describe('mobile session startup', () => { - it('auto-creates one terminal for a newly created empty session', () => { - expect(source).toContain('useWorktreeSessionTabsLoaded(worktreeId)') - expect(source).toContain( - 'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()' - ) - - const autoCreateCall = sliceBetween( - 'useInitialSessionTerminalAutoCreate({', - 'const connectionVerdict =', - presentationSource - ) - expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef') - expect(autoCreateCall).toContain( - 'consumeCreationRoute: () => router.setParams({ created: undefined })' - ) - expect(autoCreateCall).toContain("newlyCreatedWorkspace: created === '1'") - expect(autoCreateCall).toContain('visibleTabCount: visibleTabs.length') - expect(autoCreateCall).toContain('createTerminal: () => void handleCreateTerminal()') - - expect(autoCreateHookSource).toContain('shouldAutoCreateInitialSessionTerminal({') - expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree === worktreeId') - expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId') - expect(autoCreateHookSource).toContain("connState === 'connected'") - expect(autoCreateHookSource).toContain('(visibleTabCount > 0 || activeHandle !== null)') - // Why: both callbacks are re-created every render, so the effect must reach them - // through useEffectEvent rather than deps or a render-time ref write. - expect(autoCreateHookSource).toContain('useEffectEvent(args.consumeCreationRoute)') - expect(autoCreateHookSource).toContain('useEffectEvent(args.createTerminal)') - expect(autoCreateHookSource).toContain('consumeCreationRoute()') - expect(autoCreateHookSource).toContain('createTerminal()') - }) - - it('arms the auto-create only until the route has published a tab (#9717)', () => { - // Emptiness after a populated list is a close, not a cold hydrate. - expect(source).toContain( - 'initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0' - ) - - const autoCreateCall = sliceBetween( - 'useInitialSessionTerminalAutoCreate({', - 'const connectionVerdict =', - presentationSource - ) - expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef') - expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs') - }) - - it('delegates stream ownership while retaining degraded polling and a certified sweep', () => { - expect(source).toContain('useMobileSessionTabsReconciliation<') - expect(source).toContain('const applicationRevision = ++appliedSessionTabsRevisionRef.current') - expect(source).toContain('getApplicationRevision: getSessionTabsApplicationRevision') - expect(source).not.toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain("if (AppState.currentState !== 'active')") - expect(reconciliationHookSource).toContain('suspendTerminalInventoryRecovery(true)') - expect(reconciliationHookSource).toContain('controller.poll()') - expect(reconciliationHookSource).toContain('tabsRequest !== null') - expect(reconciliationHookSource).toContain('void refreshTerminalInventory()') - expect(reconciliationHookSource).toContain("AppState.addEventListener('change'") - expect(reconciliationHookSource).toContain('const interval = setInterval(') - expect(reconciliationHookSource).toContain('RECONCILIATION_INTERVAL_MS = 2000') - expect(terminalInventoryRecoverySource).toContain('CERTIFIED_TERMINAL_SWEEP_MS = 60_000') - expect(reconciliationHookSource).toContain('controller.setReconciliationActive(false)') - expect(reconciliationHookSource).toContain('clearInterval(interval)') - expect(reconciliationHookSource).toContain('appStateSubscription.remove()') - }) - - it('binds terminal identity to the shared client before subscription effects run', () => { - expect(foundationSource).toContain('const { client, clientId, state: connState }') - expect(foundationSource).toContain(' clientId,') - expect(terminalRuntimeSource).toContain('useRef(clientId)') - expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId') - expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null') - expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)') - expect(terminalSubscriptionSourceForIdentity).toContain( - "client: { id: clientId, type: 'mobile' as const }" - ) - expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken') - }) - - it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { - expect(terminalSubscriptionSource).toContain( - "if (data.type === 'end' || data.type === 'error')" - ) - expect(terminalSubscriptionSource).toContain('signalTerminalInventoryRecovery()') - expect(terminalInventoryRecoverySource).toContain('actionRef.current = recoveryAction') - expect(terminalInventoryRecoverySource).toContain('pendingSignalScopeRef.current = scopeKey') - expect(terminalInventoryRecoverySource).toContain( - 'committedScope !== null && committedScope !== scopeKey' - ) - expect(terminalListSource).toContain('return terminalInventoryRequest.activate()') - expect(terminalListSource).toContain('if (!isCurrent() || !response.accepted)') - expect(terminalInventoryRecoverySource).toContain( - 'TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750' - ) - expect(terminalInventoryRecoverySource).toContain( - 'refreshTerminalInventory({ allowEmptyLoaded: true })' - ) - }) - - it('loads session tabs without waiting for desktop activation', () => { - const startupEffect = sliceBetween( - 'void (async () => {', - 'return () => {\n disposed = true', - startupSource - ) - - // Both sends migrated to the typed `worktreeActivate` operation in step 6; what these pin is - // unchanged — the plain activation is fired and not awaited, and it goes out before the tab load. - expect(startupEffect).toContain('void worktreeActivate\n .request(client, {') - expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)") - expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)") - expect(startupEffect).toContain('notifyClients: false') - expect(startupEffect).toContain("navigation: 'caller'") - expect(startupEffect).not.toContain('await worktreeActivate\n .request(client, {') - expect(startupEffect.indexOf('worktreeActivate\n .request(client, {')).toBeLessThan( - startupEffect.indexOf('await ensureSessionTabs()') - ) - expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(activation.value)') - expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'") - }) - - it('fails runtime capability gates closed before probing a replacement client', () => { - const capabilityEffect = sliceBetween( - 'const hostQueryReplyInputSupportedRef = useRef(false)', - 'return {\n consumeAcceptedSessionTabs', - tabReconciliationSource - ) - const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityProbe(client,') - - expect(probeStart).toBeGreaterThanOrEqual(0) - for (const reset of [ - 'setBrowserScreencastSupported(null)', - 'setAgentSessionHistorySupported(null)', - 'setQuickCommandsSupported(null)', - 'setShowQuickCommands(false)', - 'hostQueryReplyInputSupportedRef.current = false' - ]) { - const resetIndex = capabilityEffect.lastIndexOf(reset) - expect(resetIndex).toBeGreaterThanOrEqual(0) - expect(resetIndex).toBeLessThan(probeStart) - } - }) - - it('activates an already-selected pending terminal tab after hydration', () => { - expect(source).toContain( - 'const pendingTerminalActivationAttemptRef = useRef(null)' - ) - expect(source).toContain('pendingTerminalActivationAttemptRef.current = null') - - const pendingActivationEffect = sliceBetween( - "if (!client || connState !== 'connected' || !activePendingTerminalTab) {", - 'return {\n bulkCloseActions', - bulkCloseSource - ) - expect(pendingActivationEffect).toContain( - 'pendingTerminalActivationAttemptRef.current === activationKey' - ) - expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,') - expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id') - expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId') - expect(pendingActivationEffect).toContain('notifyClients: false') - expect(pendingActivationEffect).toContain("navigation: 'caller'") - expect(pendingActivationEffect).toContain( - 'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)' - ) - expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()') - }) - - it('keeps ready terminal taps local while publishing caller selection', () => { - const readyTerminalSwitch = sliceBetween( - 'const switchTab = useCallback(', - 'const switchSessionTab = useCallback(', - tabSwitchingSource - ) - - expect(readyTerminalSwitch).not.toContain('focusMobileTerminal(client, handle)') - expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,') - expect(readyTerminalSwitch).toContain('notifyClients: false') - expect(readyTerminalSwitch).toContain("navigation: 'caller'") - }) - - it('keeps background and pending session-tab activation local to the phone', () => { - const activationRequests = source.split('activateMobileSessionTab(client,').slice(1) - - expect(activationRequests).toHaveLength(4) - for (const request of activationRequests) { - expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false') - expect(request.slice(0, request.indexOf('})'))).toContain("navigation: 'caller'") - } - }) - - it('keeps dynamic agent rows above fixed New Tab actions', () => { - const newTabActions = sliceBetween( - 'title="New Tab"', - 'onClose={() => setShowCreateTabDrawer', - sheetsSource - ) - - expect(newTabActions.indexOf('...createTabAgentActions')).toBeLessThan( - newTabActions.indexOf("label: 'Terminal'") - ) - expect(newTabActions.indexOf("label: 'Terminal'")).toBeLessThan( - newTabActions.indexOf("label: 'Browser'") - ) - expect(newTabActions.indexOf("label: 'Browser'")).toBeLessThan( - newTabActions.indexOf("label: 'Markdown Note'") - ) - }) - - it('wires pending-handle recovery through its bounded context (STA-4256)', () => { - const applySessionTabs = sliceBetween( - 'const applySessionTabs = useCallback(', - 'const consumeAcceptedSessionTabs = useCallback(' - ) - const recoveryContext = sliceBetween( - 'const pendingTerminalRecoveryContextCache = useMemo(', - 'const sessionTabsFetchReporting', - tabReconciliationOwnerSource - ) - - const tabsRefWrite = 'sessionTabsRef.current = nextTabs' - const tabsStateWrite = 'setSessionTabs((prev)' - const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null' - const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)' - for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) { - expect(applySessionTabs).toContain(write) - } - expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan( - applySessionTabs.indexOf(tabsStateWrite) - ) - expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan( - applySessionTabs.indexOf(activeStateWrite) - ) - expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()') - expect(recoveryContext).toContain('sessionTabsRef.current,') - expect(recoveryContext).toContain('activeSessionTabIdRef.current') - expect(recoveryContext).toContain( - 'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()' - ) - expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed') - expect(source).toContain('getPendingTerminalRecoveryContextKey,') - expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext') - expect(source).toContain('retryPendingTerminalRecovery()') - }) -}) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 6857855c9b4..1cb211affb6 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, - AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' @@ -9,13 +10,17 @@ describe('structuredAgentSessionHostSupport', () => { it('reads each structured-session feature from the host capability list', () => { expect(structuredAgentSessionHostSupport([])).toEqual({ promptCancel: false, - questionAnswers: false + questionAnswers: false, + queuedMessages: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: false, questionAnswers: true }) + ).toEqual({ promptCancel: false, questionAnswers: true, queuedMessages: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: true, questionAnswers: false }) + ).toEqual({ promptCancel: true, questionAnswers: false, queuedMessages: false }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) + ).toEqual({ promptCancel: false, questionAnswers: false, queuedMessages: true }) }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 939f7d559f4..8994bb73cac 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -1,12 +1,15 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, - AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' /** Structured-session features the connected host advertised; null until the status probe answers. */ export type StructuredAgentSessionHostSupport = { promptCancel: boolean questionAnswers: boolean + /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ + queuedMessages: boolean } export function structuredAgentSessionHostSupport( @@ -14,6 +17,7 @@ export function structuredAgentSessionHostSupport( ): StructuredAgentSessionHostSupport { return { promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), - questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY) + questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), + queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index bca14e293f7..4476b798230 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -15,8 +15,10 @@ import { agentSessionWriteNoticeParts } from '../../../src/shared/agent-session-refusal-notice' import { - agentSessionRpcErrorFailure, + agentSessionRefusalFailure, + agentSessionThrownFailure, agentSessionWriteKindForMethod, + readAgentSessionErrorRefusal, type AgentSessionWriteKind } from '../../../src/shared/agent-session-write-failure' import { structuredSessionOperationId } from './structured-session-operation-id' @@ -30,7 +32,10 @@ export const STRUCTURED_SEND_TIMEOUT_MS = 15_000 export type StructuredAgentSessionMutationCallResult = | { status: 'accepted'; value: TValue } | { status: 'refused'; code: AgentSessionWireRefusalCode; message: string } - | { status: 'failed'; message: string } + /** `hostRejectedByRequestSchema`: the host's schema turned this request away before running + * it, so the same request can never be accepted there. An auth refusal does not set it: + * it says nothing about an earlier delivery of the same id. */ + | { status: 'failed'; message: string; hostRejectedByRequestSchema?: true } /** `hostReportedOperationUnknown` separates a host answer about the id from doubt * about the effect. Whether that id can still be retried is the method's own * question: a plan that recovers an unknown ledger row replays or reruns it, one @@ -51,12 +56,36 @@ export type StructuredAgentSessionMutate = ( class AgentSessionRpcResponseError extends Error { constructor( readonly code: string, - message: string + message: string, + /** A thrown refusal's reason rides here; its message is only the bare code. */ + readonly data?: unknown ) { super(message) } } +/** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error + * frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come + * from the refusal in the error's data. */ +export function agentSessionReadFailureText(failure: unknown): string { + const refusal = readAgentSessionErrorRefusal( + typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure + ) + if (refusal) { + return agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), 'read-history') + ) + } + if (failure instanceof Error) { + return failure.message + } + return typeof failure === 'object' && failure !== null + ? 'message' in failure + ? String(failure.message ?? '') + : '' + : String(failure) +} + export async function callAgentSession( client: RpcClient, method: string, @@ -70,7 +99,11 @@ export async function callAgentSession( ...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {}) }) if (!response.ok) { - throw new AgentSessionRpcResponseError(response.error.code, response.error.message) + throw new AgentSessionRpcResponseError( + response.error.code, + response.error.message, + response.error.data + ) } return response.result as TResult } @@ -186,14 +219,19 @@ export async function requestStructuredAgentSessionMutation(args: { } } catch (error) { const answered = - error instanceof AgentSessionRpcResponseError ? agentSessionRpcErrorFailure(error.code) : null + error instanceof AgentSessionRpcResponseError + ? agentSessionThrownFailure(error, error.code) + : null if (answered && answered.kind !== 'unconfirmed') { // The host turned the request away before running it; its text is written for a log. return { status: 'failed', message: agentSessionWriteNoticeEnglish( agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields)) - ) + ), + ...(error instanceof AgentSessionRpcResponseError && error.code === 'invalid_argument' + ? { hostRejectedByRequestSchema: true } + : {}) } } if ( diff --git a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts new file mode 100644 index 00000000000..c9da99a3cba --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts @@ -0,0 +1,157 @@ +// A resend past a saved record storage would not clear goes out under a fresh id. A retry of that +// text in the same app run must replay that id, never mint another: if the first resend's answer +// was lost, a second fresh id could deliver the message twice. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { RpcResponse } from '../transport/types' +import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +function ok(result: unknown): RpcResponse { + return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): RpcResponse { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { clientMessageId, queued: { messageId: clientMessageId, position: 1, state } } + }) +} + +/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */ +function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { + const ids: string[] = [] + const deliveries: unknown[] = [] + const sendRequest = vi.fn(async (_method, params) => { + const envelope = Object(Object(params).envelope) + const id = String(envelope.clientOperationId) + const answer = answers[ids.length] + ids.push(id) + deliveries.push(Object(params).delivery) + if (answer === 'lost' || answer === undefined) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return queuedAnswer(id, answer) + }) + const client: RpcClient = { + sendRequest, + subscribe: vi.fn(() => vi.fn()), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + return { client, ids, deliveries } +} + +function sendAgain(client: RpcClient, onError: (message: string) => void, queue = true) { + return sendMobileStructuredAgentSessionMessage({ + client, + sessionId: 'session-1', + sessionKey: 'host-a:session-1', + callerIdentity: 'device-a', + expectedRuntimeFence: 3, + text: 'again', + attachments: [], + ...(queue ? { delivery: 'queue-if-active' as const } : {}), + onError + }) +} + +describe('a resend past a saved record storage would not clear', () => { + let stored: Map + + beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + stored = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + stored.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + stored.delete(key) + }) + }) + + it('replays its own id once storage recovers, even without the queue capability now', async () => { + const { client, ids, deliveries } = hostAnswering([ + 'lost', + 'withdrawn', + 'lost', + 'withdrawn', + 'waiting' + ]) + const onError = vi.fn() + expect(await sendAgain(client, onError)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError)).toBe('unknown') + // Storage recovers: the lost send's record now clears, and the queue capability is gone. + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + stored.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + stored.delete(key) + }) + expect(await sendAgain(client, onError, false)).toBe('queued') + expect(ids).toHaveLength(5) + // The resend's id is replayed, as first sent, never replaced by a fresh one. + expect(ids[4]).toBe(ids[2]) + expect(deliveries[4]).toBe('queue-if-active') + }) + + it('replays its own id on a retry after a lost answer, and reports the record only once sent', async () => { + // Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's + // withdrawn replay; then the resend answered. + const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'waiting']) + const onError = vi.fn() + const send = () => + sendMobileStructuredAgentSessionMessage({ + client, + sessionId: 'session-1', + sessionKey: 'host-a:session-1', + callerIdentity: 'device-a', + expectedRuntimeFence: 3, + text: 'again', + attachments: [], + delivery: 'queue-if-active', + onError + }) + expect(await send()).toBe('unknown') + // From here the lost send's record can never be cleared. + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + + expect(await send()).toBe('unknown') + // An unconfirmed resend may not have gone out, so nothing says it was sent. + expect(onError).not.toHaveBeenCalled() + + expect(await send()).toBe('queued') + expect(ids).toHaveLength(5) + expect(ids[1]).toBe(ids[0]) + expect(ids[3]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + // The retry replays the resend's id rather than minting one that could deliver twice. + expect(ids[4]).toBe(ids[2]) + expect(onError).toHaveBeenCalledWith( + "Sent, but this phone couldn't update its record of sent messages." + ) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 82d237d4d4a..ce7f60a8b92 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -16,9 +16,12 @@ import { import { structuredSessionOperationId } from './structured-session-operation-id' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import { + bypassedMobileStructuredSendOperationId, clearMobileStructuredSendOperation, + forgetBypassedMobileStructuredSendOperation, getOrCreateMobileStructuredSendOperation, - mobileStructuredSendOperationKey + mobileStructuredSendOperationKey, + rememberBypassedMobileStructuredSendOperation } from './mobile-structured-send-operation-journal' export async function sendMobileStructuredAgentSessionMessage(input: { @@ -29,8 +32,19 @@ export async function sendMobileStructuredAgentSessionMessage(input: { expectedRuntimeFence: number text: string attachments: readonly (StructuredAgentSessionAttachment & { contentFingerprint?: string })[] + /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ + delivery?: 'queue-if-active' deadline?: number onError: (message: string) => void + /** Internal: the one fresh-id resend after a withdrawn replay. */ + resendingAfterWithdrawal?: true + /** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the + * record, so failing storage never blocks the send; its id is kept in memory for this app run, + * so a retry after a lost answer replays it rather than sending again. */ + bypassRetainedRecord?: true + /** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed + * by it, so a capability change since the lost answer never mints another id. */ + resendOperationKey?: string }): Promise { const timeoutMs = timeoutForDeadline(input.deadline) if (timeoutMs === null) { @@ -43,39 +57,68 @@ export async function sendMobileStructuredAgentSessionMessage(input: { sessionId: input.sessionId, fields: { body: requestedBody } }) - const intentFingerprint = structuredAgentSessionDomainFingerprint({ - domain: 'mobile.agentSession.send.intent', - sessionId: input.sessionKey, - fields: { - text: input.text.trimEnd(), - attachments: input.attachments.map( - (attachment) => - attachment.contentFingerprint ?? - structuredAgentSessionDomainFingerprint({ - domain: 'mobile.nativeChat.image.preview', - sessionId: '', - fields: { previewUri: attachment.previewUri } - }) - ) - } - }) - const operationKey = mobileStructuredSendOperationKey({ - sessionKey: input.sessionKey, - intentFingerprint - }) + const intentFields = { + text: input.text.trimEnd(), + attachments: input.attachments.map( + (attachment) => + attachment.contentFingerprint ?? + structuredAgentSessionDomainFingerprint({ + domain: 'mobile.nativeChat.image.preview', + sessionId: '', + fields: { previewUri: attachment.previewUri } + }) + ) + } + // `delivery` is part of the intent key, never a stored journal field: the + // immediate key is exactly today's, so an older build still reads the journal. + const operationKeyFor = (delivery: 'queue-if-active' | undefined): string => + mobileStructuredSendOperationKey({ + sessionKey: input.sessionKey, + intentFingerprint: structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.intent', + sessionId: input.sessionKey, + fields: delivery ? { ...intentFields, delivery } : intentFields + }) + }) + const queuedOperationKey = operationKeyFor('queue-if-active') + const immediateOperationKey = operationKeyFor(undefined) + const requestedOperationKey = input.delivery ? queuedOperationKey : immediateOperationKey + const attachmentPaths = input.attachments.map((attachment) => attachment.path) + const resendKey = input.resendOperationKey ?? requestedOperationKey let operation: Awaited> try { - operation = await getOrCreateMobileStructuredSendOperation({ - operationKey, - callerIdentity: input.callerIdentity, - payloadFingerprint: requestedPayloadFingerprint, - attachmentPaths: input.attachments.map((attachment) => attachment.path), - createOperationId: structuredSessionOperationId - }) + if (input.bypassRetainedRecord) { + operation = bypassOperation(resendKey, requestedPayloadFingerprint, attachmentPaths) + } else if ( + input.resendOperationKey !== undefined && + bypassedMobileStructuredSendOperationId(resendKey) !== undefined + ) { + // Storage recovered after a bypassed resend's answer was lost: that id is replayed, never + // replaced by a fresh one that could deliver the message twice. + operation = await adoptBypassedOperation({ + operationKey: resendKey, + callerIdentity: input.callerIdentity, + payloadFingerprint: requestedPayloadFingerprint, + attachmentPaths + }) + } else { + operation = await getOrCreateMobileStructuredSendOperation({ + operationKey: requestedOperationKey, + // A retained id replays exactly as first sent, whatever the capability says now; + // a host that refuses that request shape retires it, so the next send goes out fresh. + alternateOperationKey: input.delivery ? immediateOperationKey : queuedOperationKey, + callerIdentity: input.callerIdentity, + payloadFingerprint: requestedPayloadFingerprint, + attachmentPaths, + createOperationId: structuredSessionOperationId + }) + } } catch { input.onError('Message not sent') return 'rejected' } + const operationKey = operation.operationKey + const delivery = operationKey === queuedOperationKey ? 'queue-if-active' : undefined const body = structuredAgentSessionSendBody( input.text, operation.attachmentPaths.map((path) => ({ path, previewUri: '' })) @@ -95,24 +138,105 @@ export async function sendMobileStructuredAgentSessionMessage(input: { fingerprintMethod: 'agentSession.send', sessionId: input.sessionId, expectedRuntimeFence: input.expectedRuntimeFence, - fields: { body }, + // `delivery` joins the wire fields — and so the operation fingerprint — but + // never the journal's body-only fingerprint the submission echo recomputes. + fields: { body, ...(delivery ? { delivery } : {}) }, clientOperationId: operation.operationId, timeoutMs }) - const delivery = mobileStructuredSendDelivery(result, operation.retained) - if (delivery.operationIdSpent) { + const outcome = mobileStructuredSendDelivery(result, operation.retained) + if (input.bypassRetainedRecord && outcome.operationIdSpent) { + forgetBypassedMobileStructuredSendOperation(operationKey, operation.operationId) + } + let released = false + if (outcome.operationIdSpent) { try { await clearMobileStructuredSendOperation({ operationKey, operationId: operation.operationId }) + released = true } catch { // A retained settled id can suppress a later identical send, never // duplicate this one; the next replay gets another clear chance. } } - if (delivery.error !== null) { - input.onError(delivery.error) + const withdrawnReplay = + result.status === 'accepted' && + 'queued' in result.value && + result.value.queued?.state === 'withdrawn' + if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { + // The retained id's draft was withdrawn, so it never reached the agent: + // this identical message is a new one, not a replay to swallow. A record + // storage would not clear is bookkeeping: it is reported, never allowed to + // block the send. + const resent = await sendMobileStructuredAgentSessionMessage({ + ...input, + resendingAfterWithdrawal: true, + resendOperationKey: operationKey, + ...(released ? {} : { bypassRetainedRecord: true as const }) + }) + // Only when the resend is known to have gone out; an unconfirmed one may not have. + if (!released && (resent === 'accepted' || resent === 'queued')) { + input.onError("Sent, but this phone couldn't update its record of sent messages.") + } + return resent + } + if (withdrawnReplay) { + // Not resent: no card and no bubble holds the text, so it goes back to the + // composer rather than vanishing. + input.onError('Message not sent') + return 'rejected' + } + if (outcome.error !== null) { + input.onError(outcome.error) + } + return outcome.outcome +} + +/** The id a resend past an uncleared record goes out under: the one this app run already used + * for this text, replayed, or a fresh one remembered for the next retry. */ +function bypassOperation( + operationKey: string, + payloadFingerprint: string, + attachmentPaths: string[] +): Awaited> { + const bypassed = bypassedMobileStructuredSendOperationId(operationKey) + const operationId = bypassed ?? structuredSessionOperationId() + if (bypassed === undefined) { + rememberBypassedMobileStructuredSendOperation(operationKey, operationId) + } + return { + operationKey, + operationId, + retained: bypassed !== undefined, + payloadFingerprint, + attachmentPaths + } +} + +/** A remembered bypassed id handed back to the saved record once storage works again: the record + * replays it, and memory lets it go. Should storage fail again, memory keeps replaying it. */ +async function adoptBypassedOperation(input: { + operationKey: string + callerIdentity: string + payloadFingerprint: string + attachmentPaths: string[] +}): Promise>> { + const operationId = bypassedMobileStructuredSendOperationId(input.operationKey) + if (operationId === undefined) { + throw new Error('No bypassed send id to adopt') + } + try { + const recorded = await getOrCreateMobileStructuredSendOperation({ + ...input, + createOperationId: () => operationId + }) + if (recorded.operationId === operationId) { + forgetBypassedMobileStructuredSendOperation(input.operationKey, operationId) + } + return { ...recorded, retained: true } + } catch { + return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths) } - return delivery.outcome } diff --git a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts new file mode 100644 index 00000000000..065845640b3 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + agentSessionReadFailureText, + callAgentSession, + requestStructuredAgentSessionMutation +} from './mobile-structured-agent-session-rpc' + +// As `mapRuntimeError` sends a thrown refusal (pinned in `src/main/runtime/rpc/errors.test.ts`): +// its message is the bare code, and its reason rides in data. +const THROWN_JOURNAL_REFUSAL = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } +} + +function refusingClient(): RpcClient { + const sendRequest = async () => ({ id: 'req-1', ok: false, error: THROWN_JOURNAL_REFUSAL }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the paths under test reach only `sendRequest`. + return { sendRequest } as unknown as RpcClient +} + +describe('a refusal the host threw', () => { + it("reads a chat's history failure in the refusal's words, from a stream or a request", async () => { + const words = 'Unable to load this chat.' + // The stream's error frame, as the RPC client hands it over. + expect( + agentSessionReadFailureText({ + type: 'error', + message: THROWN_JOURNAL_REFUSAL.message, + error: THROWN_JOURNAL_REFUSAL + }) + ).toBe(words) + const thrown = await callAgentSession(refusingClient(), 'agentSession.history', {}).catch( + (error: unknown) => error + ) + expect(agentSessionReadFailureText(thrown)).toBe(words) + expect(agentSessionReadFailureText({ type: 'error', message: 'Connection interrupted' })).toBe( + 'Connection interrupted' + ) + }) + + it("fails a write with the refusal's words, not as unconfirmed", async () => { + const result = await requestStructuredAgentSessionMutation({ + client: refusingClient(), + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId: 'session-1', + expectedRuntimeFence: 3, + fields: { turnId: 'turn-1' }, + clientOperationId: `1900000000000-${'c'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'failed', + message: "Unable to load this chat. The agent wasn't stopped." + }) + }) +}) + +describe('a write refused on a journal a newer Orca wrote', () => { + // As the host answers it (pinned in `journal-open-failure.test.ts`). + const newerOrcaClient = (): RpcClient => { + const sendRequest = async () => ({ + id: 'req-1', + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the paths under test reach only `sendRequest`. + return { sendRequest } as unknown as RpcClient + } + + it.each([ + [ + 'agentSession.send', + { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } }, + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ], + [ + 'agentSession.cancel', + { turnId: 'turn-1' }, + "Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them." + ] + ] as const)('says to update Orca for %s', async (method, fields, message) => { + const result = await requestStructuredAgentSessionMutation({ + client: newerOrcaClient(), + method, + fingerprintMethod: method, + sessionId: 'session-1', + expectedRuntimeFence: 3, + fields, + clientOperationId: `1900000000000-${'d'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'refused', + code: 'agent_session_journal_unreadable', + message + }) + }) +}) diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index d37e337f091..41a8a986b1c 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -1,9 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' function setup() { - const sendRequest = vi.fn(async (_method: string, _params: unknown, _options: unknown) => ({ + const sendRequest = vi.fn< + (method: string, params: unknown, options: unknown) => Promise + >(async () => ({ ok: true, result: { ok: true, value: { command: 'compact', state: 'completed' } } })) @@ -29,7 +40,17 @@ function setup() { } return { input, sendRequest } } +/** The fields one recorded request carried, read without asserting their shape. */ +function requestFields(call: readonly unknown[] | undefined): Record { + const params = call?.[1] + return typeof params === 'object' && params !== null + ? Object.fromEntries(Object.entries(params)) + : {} +} describe('mobile structured conversation commands', () => { + beforeEach(() => { + vi.clearAllMocks() + }) it.each(['/clear', '/compact'])( 'uses the command RPC for %s without an ordinary send', async (text) => { @@ -97,6 +118,16 @@ describe('mobile structured conversation commands', () => { expect(input.onError).toHaveBeenCalled() } ) + it('/clear is a plain command on every host: no withdrawal fields, nothing persisted', async () => { + // The host carries queued cards to the replacement session itself; the + // client asks for nothing back and has nothing to restore. + const { input, sendRequest } = setup() + expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('accepted') + const fields = requestFields(sendRequest.mock.calls[0]) + expect(Object.keys(fields).sort()).toEqual(['command', 'envelope']) + expect(fields.command).toBe('clear') + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) it('keeps ordinary messages on the existing send path', async () => { const { input, sendRequest } = setup() expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull() diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts new file mode 100644 index 00000000000..2c6173b54a5 --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' +import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' +import { + mobileQueueHasResumableCard, + mobileQueuePauseLabel, + mobileQueuedMessageCards +} from './mobile-structured-queued-message-cards' + +/** A returned card as the host publishes it: the refusal's sentence and its typed fact. */ +function returnedAs(fact: Parameters[0]) { + const { reason, rejection } = agentSessionFailureWords(fact, { surface: 'rejection' }) + return { state: 'returned' as const, returnedReason: reason, returnedRejection: rejection } +} + +function draft(overrides: Partial & { messageId: string }) { + return { + position: 1, + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: `body of ${overrides.messageId}` }] + }, + state: 'waiting' as const, + ...overrides + } +} + +describe('mobileQueuedMessageCards', () => { + it('renders nothing without a published list', () => { + expect(mobileQueuedMessageCards(null, [], { pendingPrompt: false })).toEqual([]) + expect(mobileQueuedMessageCards([], [], { pendingPrompt: false })).toEqual([]) + }) + + it('captions nothing on a plain waiting draft, as the desktop row', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { + pendingPrompt: false + }) + expect(card).toEqual({ + messageId: 'a', + text: 'body of a', + state: 'waiting', + paused: false, + needsAttention: false, + caption: null + }) + }) + + it('captions a waiting draft behind a pending prompt', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { + pendingPrompt: true + }) + expect(card?.caption).toBe('Waiting for your answer') + }) + + it('captions nothing on a waiting card of a paused queue; its pause row explains', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { + pendingPrompt: false, + queuePaused: true + }) + expect(card?.caption).toBeNull() + expect(card?.paused).toBe(false) + expect(card?.needsAttention).toBe(false) + }) + + it("keeps a card's own failed send and reads a prompt's wait as queued under a paused queue", () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'a', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }), + draft({ messageId: 'b', position: 2 }) + ], + [], + { pendingPrompt: true, queuePaused: true } + ) + expect(cards.map((card) => card.caption)).toEqual(["Couldn't send — tap Send to retry", null]) + expect(cards[0]?.paused).toBe(true) + }) + + it('finds something for Resume to send only in a waiting card with no hold, ahead of a returned one', () => { + const resumable = (drafts: AgentSessionQueuedMessage[]) => + mobileQueueHasResumableCard( + mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, queuePaused: true }) + ) + const returned = draft({ + messageId: 'r', + ...returnedAs(agentSessionFailureFact('hostRestarted')) + }) + const failed = draft({ + messageId: 'f', + paused: true, + pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED + }) + const waiting = draft({ messageId: 'w', position: 2 }) + expect(resumable([returned])).toBe(false) + expect(resumable([returned, waiting])).toBe(false) + expect(resumable([failed])).toBe(false) + expect(resumable([failed, waiting])).toBe(true) + expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) + }) + + it('words the paused queue by reason, and one this build does not know as a plain pause', () => { + expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( + 'Queue paused because you interrupted' + ) + expect(mobileQueuePauseLabel({ reason: 'restarted' })).toBe( + 'Queue paused because Orca restarted' + ) + expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( + 'Queue paused after you cleared the conversation' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reason newer than this build's union, as a newer host would send it. + const newer = { reason: 'later_reason' } as never + expect(mobileQueuePauseLabel(newer)).toBe('Queue paused') + }) + + it('captions a reasonless pause as a plain pause, promising no release rule', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a', paused: true })], [], { + pendingPrompt: false + }) + expect(card?.caption).toBe('Paused') + }) + + it("shows a returned card with the provider's own words and holds drafts behind it", () => { + const refused = agentSessionFailureFact('providerRejected', { + detail: { text: 'Steering is unavailable', audience: 'person' } + }) + const cards = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(refused) }), draft({ messageId: 'b', position: 2 })], + [], + { pendingPrompt: false } + ) + expect(cards[0]?.caption).toContain('Steering is unavailable') + expect(cards[0]?.state).toBe('returned') + expect(cards[0]?.needsAttention).toBe(true) + expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') + expect(cards[1]?.needsAttention).toBe(false) + }) + + it('hides a waiting card once its hand-off arrived, never a returned one', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'returned', ...returnedAs(agentSessionFailureFact('hostRestarted')) }), + draft({ messageId: 'drained', position: 2 }), + draft({ messageId: 'waiting', position: 3 }) + ], + [ + { queuedMessageId: 'returned', dispatchState: 'rejected' }, + { queuedMessageId: 'drained', dispatchState: 'pending' } + ], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.messageId)).toEqual(['returned', 'waiting']) + expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') + }) + + it('shows a draft a Stop requeued beside its rejected hand-off', () => { + // The requeued draft keeps its id; its withdrawn hand-off stays in the journal as rejected and + // the transcript hides it, so hiding the card too would leave the text unreachable. + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'requeued' }), + draft({ messageId: 'sending', position: 2 }), + draft({ messageId: 'sent', position: 3 }) + ], + [ + { queuedMessageId: 'requeued', dispatchState: 'rejected' }, + { queuedMessageId: 'sending', dispatchState: 'pending' }, + { queuedMessageId: 'sent', dispatchState: 'accepted' } + ], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.messageId)).toEqual(['requeued']) + }) + + it('maps a Stop-withdrawn returned card to its own English copy', () => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('cancelled')) })], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Stopped before it was sent') + }) + + it('reads a Stop withdrawal from the fact, whatever sentence rides beside it', () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: 'This message was withdrawn before the agent started it.', + returnedRejection: { kind: 'cancelled' } + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Stopped before it was sent') + }) + + it('words a host-restart returned card from its fact, as a rejected send', () => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('hostRestarted')) })], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Orca restarted before this message was sent.') + }) + + it("keeps a provider's log-only detail off the card", () => { + const refused = agentSessionFailureFact('providerRejected', { + detail: { text: 'stack trace for the log', audience: 'log' } + }) + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(refused) })], + [], + { + pendingPrompt: false + } + ) + expect(card?.caption).not.toContain('stack trace') + }) + + it("words a fact kind this build cannot place with the host's sentence", () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: 'Words for a kind a newer host added.', + returnedRejection: { kind: 'laterKind' } + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Words for a kind a newer host added.') + }) + + it('maps the send-failed pause marker to English and an unknown marker to a plain pause', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'a', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }), + // SAFETY: a newer host's marker this build has no vocabulary for. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: simulates a marker from a newer host than this build's union. + draft({ messageId: 'b', position: 2, paused: true, pausedReason: 'later_marker' as never }) + ], + [], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.caption)).toEqual([ + "Couldn't send — tap Send to retry", + 'Paused' + ]) + // Only a failed send alerts; a plain pause is not the card's fault. + expect(cards.map((card) => card.needsAttention)).toEqual([true, false]) + }) + + it('never shows an internal rejection reason verbatim, even from a host that wrote no fact', () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: DISPATCH_REJECTED_HOST_RESTARTED + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Your message was not sent.') + }) +}) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts new file mode 100644 index 00000000000..16393dbbf8b --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -0,0 +1,141 @@ +// Render models for the host-held queued drafts shown above the composer. +// The wire carries no hold copy on purpose: the caption is derived here from the +// draft's own state plus the live facts the client already holds. + +import { readAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from '../../../src/shared/agent-session-wire' + +export type MobileQueuedMessageCard = { + messageId: string + /** The draft's text blocks joined for display and for Edit's composer copy. */ + text: string + state: 'waiting' | 'returned' + paused: boolean + /** Returned, or its own send failed: the row leads with an alert. */ + needsAttention: boolean + /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ + caption: string | null +} + +function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { + return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') +} + +function returnedCaption( + draft: Pick +): string { + const reason = draft.returnedReason ?? null + const rejection = draft.returnedRejection + if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { + return 'Stopped before it was sent' + } + // Worded as the desktop card words it: the fact decides, the reason is the fallback, and the + // card's own Send is the retry, so the words leave out sending again. + return agentSessionWriteNoticeEnglish( + structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason }, + { retryControl: true }, + readAgentSessionFailureFact(rejection) + ) + ) +} + +/** One card's own hold: only a failed conversion; the queue's pause is the list's first row. */ +function pausedCaption(reason: string | undefined): string { + if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { + return "Couldn't send — tap Send to retry" + } + // Absent or unknown (newer host) marker: a plain pause, promising no release rule. + return 'Paused' +} + +const QUEUE_PAUSE_LABELS: Readonly> = { + stopped: 'Queue paused because you interrupted', + restarted: 'Queue paused because Orca restarted', + cleared: 'Queue paused after you cleared the conversation' +} + +/** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any + * returned card. The drain stops at a returned card, so cards behind one never go. */ +export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { + for (const card of cards) { + if (card.state === 'returned') { + return false + } + if (!card.paused) { + return true + } + } + return false +} + +/** The paused queue's first row. A reason this build does not know (a newer host's) reads as a + * plain pause. */ +export function mobileQueuePauseLabel(pause: Pick): string { + return QUEUE_PAUSE_LABELS[pause.reason] ?? 'Queue paused' +} + +/** Cards in published order. A waiting card is hidden once a live hand-off of it arrived — a + * submission naming it as its `queuedMessageId` that was not rejected — as the desktop hides it: + * on a multi-page catch-up the shrunk list rides only the final page, so the bubble and the card + * would otherwise briefly show together. A rejected hand-off is what sent the draft back, so it + * hides nothing. Returned cards always show. */ +export function mobileQueuedMessageCards( + queuedMessages: readonly AgentSessionQueuedMessage[] | null, + submissions: readonly Pick[], + facts: { pendingPrompt: boolean; queuePaused?: boolean } +): MobileQueuedMessageCard[] { + if (!queuedMessages || queuedMessages.length === 0) { + return [] + } + const handedOff = new Set( + submissions.flatMap((submission) => + submission.queuedMessageId !== undefined && submission.dispatchState !== 'rejected' + ? [submission.queuedMessageId] + : [] + ) + ) + let behindReturned = false + const cards: MobileQueuedMessageCard[] = [] + for (const draft of queuedMessages) { + if (draft.state !== 'returned' && handedOff.has(draft.messageId)) { + continue + } + const paused = draft.paused === true + const caption = + draft.state === 'returned' + ? returnedCaption(draft) + : paused + ? pausedCaption(draft.pausedReason) + : behindReturned + ? 'Waiting — a message ahead needs attention' + : facts.queuePaused + ? // The pause row says why and offers Resume; the card promises no send time. + null + : facts.pendingPrompt + ? 'Waiting for your answer' + : null + cards.push({ + messageId: draft.messageId, + text: queuedMessageBodyText(draft.body), + state: draft.state, + paused, + needsAttention: + draft.state === 'returned' || + (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), + caption + }) + if (draft.state === 'returned') { + behindReturned = true + } + } + return cards +} diff --git a/mobile/src/session/mobile-structured-queued-message-feed.test.ts b/mobile/src/session/mobile-structured-queued-message-feed.test.ts new file mode 100644 index 00000000000..3b7eb9cf15c --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-feed.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import { mobileQueuePauseLabel } from './mobile-structured-queued-message-cards' +import { + reduceMobileQueuePause, + reduceMobileQueuedMessageFeed +} from './mobile-structured-queued-message-feed' + +function draft(messageId: string, position: number): AgentSessionQueuedMessage { + return { + messageId, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `text ${messageId}` }] }, + state: 'waiting' + } +} + +function batch( + queuedMessages?: AgentSessionQueuedMessage[] | null, + queuePause?: AgentSessionQueuePause | null +): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-1', + batch: { cursor: { epoch: 'e', sequence: 1 }, items: [], removedItemIds: [], submissions: [] }, + ...(queuedMessages !== undefined ? { queuedMessages } : {}), + ...(queuePause !== undefined ? { queuePause } : {}) + } +} + +describe('reduceMobileQueuePause', () => { + it('rides with the list: a frame without the list keeps it, one with the list states it', () => { + const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], { reason: 'stopped' })) + expect(paused).toEqual({ reason: 'stopped' }) + expect(reduceMobileQueuePause(paused, batch())).toBe(paused) + expect(reduceMobileQueuePause(paused, batch([draft('a', 1)], { reason: 'stopped' }))).toBe( + paused + ) + expect(reduceMobileQueuePause(paused, batch([draft('a', 1)]))).toBeNull() + }) + + it('keeps a pause that names no reason, which reads as a plain pause', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed or newer host's pause, with no reason this build can read. + const reasonless = {} as AgentSessionQueuePause + const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], reasonless)) + expect(paused).toBe(reasonless) + expect(mobileQueuePauseLabel(reasonless)).toBe('Queue paused') + }) +}) + +describe('reduceMobileQueuedMessageFeed', () => { + it('holds no claim until the host publishes the field', () => { + expect(reduceMobileQueuedMessageFeed(null, batch())).toBeNull() + }) + + it('adopts a published list ordered by position', () => { + const next = reduceMobileQueuedMessageFeed(null, batch([draft('b', 2), draft('a', 1)])) + expect(next?.map((entry) => entry.messageId)).toEqual(['a', 'b']) + }) + + it('keeps the last list when a frame omits the field', () => { + const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) + expect(reduceMobileQueuedMessageFeed(held, batch())).toBe(held) + }) + + it('reads null as empty', () => { + const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) + expect(reduceMobileQueuedMessageFeed(held, batch(null))).toEqual([]) + }) + + it('never advances on the end frame', () => { + const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) + expect(reduceMobileQueuedMessageFeed(held, { type: 'end' })).toBe(held) + }) +}) diff --git a/mobile/src/session/mobile-structured-queued-message-feed.ts b/mobile/src/session/mobile-structured-queued-message-feed.ts new file mode 100644 index 00000000000..d03c08d2edd --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-feed.ts @@ -0,0 +1,53 @@ +// The host publishes its queued drafts whole-list on the subscribe stream: +// present = the current list, absent = unchanged since the last frame this +// subscriber was sent, `null` = empty. History pages are ignored here — the +// live stream is authoritative and a stale history answer must never replace +// a newer live list. + +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' + +/** Null = no claim yet (older host, or nothing published on this stream). */ +export type MobileQueuedMessageFeed = AgentSessionQueuedMessage[] | null + +/** The whole queue's pause; null when it sends on its own or nothing was published. */ +export type MobileQueuePause = AgentSessionQueuePause | null + +/** The pause rides with the list: a frame that publishes the list states it, and one that omits + * the list leaves it unchanged. */ +export function reduceMobileQueuePause( + previous: MobileQueuePause, + event: AgentSessionSubscribeEvent +): MobileQueuePause { + if (event.type === 'end' || event.queuedMessages === undefined) { + return previous + } + const next = event.queuePause ?? null + // Presence first: a pause with no reason, or one this build does not know, is still a pause. + if ((next === null) !== (previous === null)) { + return next + } + return next?.reason === previous?.reason ? previous : next +} + +export function reduceMobileQueuedMessageFeed( + previous: MobileQueuedMessageFeed, + event: AgentSessionSubscribeEvent +): MobileQueuedMessageFeed { + if (event.type === 'end') { + return previous + } + if (event.queuedMessages === undefined) { + return previous + } + const list = event.queuedMessages ?? [] + if (list.length === 0 && previous !== null && previous.length === 0) { + return previous + } + // Host order is authoritative; sort defensively so card order never depends + // on publication order. + return [...list].sort((left, right) => left.position - right.position) +} diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index dec55369344..6305bf75df5 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -39,6 +39,69 @@ describe('mobileStructuredSendDelivery', () => { } }) + it('classifies a queued draft answer as spent, card-rendered, never a bubble', () => { + // The host holds the message now; a later identical send is a new message. + // A dispatched draft answers as `queued` only when the host lost its + // submission, so no echo would retire a bubble: it too shows nothing. + for (const state of ['waiting', 'dispatched', 'returned', 'withdrawn'] as const) { + const queued: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: { + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state } + } + } + const outcome = 'queued' + expect(mobileStructuredSendDelivery(queued)).toEqual({ + outcome, + operationIdSpent: true, + error: null + }) + expect(mobileStructuredSendDelivery(queued, true)).toEqual({ + outcome, + operationIdSpent: true, + error: null + }) + } + }) + + it("reads a replay answered by its draft's hand-off as unconfirmed, and spends the id", () => { + // The hand-off names the replayed id as its draft: the host's answer states the link, so the + // id is spent without waiting for a stream that may never carry the hand-off. + const handedOff = structuredSendResultFixture('accepted') + if (!('submission' in handedOff)) { + throw new Error('expected a submission answer') + } + const replay: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: { + clientMessageId: 'retained-draft-id', + submission: { + ...handedOff.submission, + clientMessageId: 'fresh-id', + queuedMessageId: 'retained-draft-id' + } + } + } + expect(mobileStructuredSendDelivery(replay, true)).toEqual({ + outcome: 'unknown', + operationIdSpent: true, + error: null + }) + }) + + it('never spends a retained id on a malformed answer with no submission and no id', () => { + const malformed: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: JSON.parse('{}') + } + expect(mobileStructuredSendDelivery(malformed, true)).toEqual({ + outcome: 'unknown', + operationIdSpent: false, + error: null + }) + }) + it('does not report a retained payload replay as a new accepted send', () => { for (const dispatchState of ['accepted', 'pending'] as const) { expect(mobileStructuredSendDelivery(accepted(dispatchState), true)).toEqual({ @@ -129,6 +192,33 @@ describe('mobileStructuredSendDelivery', () => { }) }) + it('releases a replay only when the host refuses its request shape itself', () => { + // An older host's strict schema refuses `delivery` before it runs anything: + // that replay can never be accepted, so keeping the id refuses the text forever. + expect( + mobileStructuredSendDelivery( + { + status: 'failed', + message: 'Your message was not sent. Send it again.', + hostRejectedByRequestSchema: true + }, + true + ) + ).toEqual({ + outcome: 'rejected', + operationIdSpent: true, + error: 'Your message was not sent. Send it again.' + }) + // Any other refusal (an auth failure, a host without the method) proves nothing + // about an earlier delivery of this id. + expect( + mobileStructuredSendDelivery( + { status: 'failed', message: 'Your message was not sent.' }, + true + ) + ).toMatchObject({ operationIdSpent: false }) + }) + it('fails closed when an invalid host response omits the required submission', () => { const result = { status: 'accepted', diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index b8d0246f5e7..466a0d29ff8 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -15,12 +15,17 @@ // message is a new message and must carry a new id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, -// keeps it because neither proves a retained delivery did not happen. +// keeps it because neither proves a retained delivery did not happen. The +// one exception is a host that refuses the replay's request shape itself +// (an older host's strict schema turning `delivery` away): that host can +// never accept the replay, so keeping the id would only refuse every later +// send of the same text. // unknown — the one answer that KEEPS its id, whether it came from the host or // from an ack-loss on the way back. The message may be with the provider, so // the retry has to stay a replay. Rotating here is what sent one message to a // model five times. +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' @@ -56,11 +61,28 @@ export function mobileStructuredSendDelivery( if (result.status !== 'accepted') { return { outcome: 'rejected', - operationIdSpent: !retained, + operationIdSpent: !retained || result.hostRejectedByRequestSchema === true, error: result.message } } - const submission = result.value.submission as AgentSessionSendResult['submission'] | undefined + if ('queued' in result.value && result.value.queued) { + // The host holds (or already settled) the draft: the send is spent — a + // later identical message is a new message. A withdrawn replay is spent + // too, never unknown: its card was deleted or carried by a /clear, and the + // caller resends it or hands the text back. A dispatched draft answers here + // only when the host could not find the submission it became (a live one + // answers with that submission), so no echo would retire an optimistic + // bubble: it shows nothing, and the transcript or the card owns the text. + return { outcome: 'queued', operationIdSpent: true, error: null } + } + const submission: AgentJournalSubmission | undefined = + 'submission' in result.value ? result.value.submission : undefined + if (submission !== undefined && submission.queuedMessageId === result.value.clientMessageId) { + // The host says this id's queued draft was handed off as that submission: the send reached + // it, so the id is spent now, not when a stream that may never carry the hand-off shows it. + // Which send the phone meant stays unconfirmed, as for any retained replay of a live send. + return { outcome: 'unknown', operationIdSpent: true, error: null } + } if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index cee58bded95..3c76e1d1c3f 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -187,6 +187,39 @@ describe('mobile structured send operation journal', () => { expect(values.size).toBe(0) }) + it("clears an id a draft hand-off names, whatever that hand-off's state", async () => { + const sessionKey = 'host-a:session-a' + const payloadFingerprint = '9'.repeat(64) + const operationKey = mobileStructuredSendOperationKey({ + sessionKey, + intentFingerprint: payloadFingerprint + }) + const operationId = operationIdAt(NOW, '7') + await getOrCreateMobileStructuredSendOperation({ + operationKey, + payloadFingerprint, + createOperationId: () => operationId, + now: NOW + }) + // The drain handed the draft off under a fresh id; only the link names this send. + await clearMobileStructuredSettledSendOperations({ + submissions: [ + { + clientMessageId: operationIdAt(NOW + 1, '8'), + queuedMessageId: operationId, + fence: 1, + payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW + 1, + resolvedAt: null + } + ] + }) + expect(values.size).toBe(0) + }) + it('does not clear a newer id for an older matching-payload submission', async () => { const sessionKey = 'host-a:session-a' const payloadFingerprint = 'e'.repeat(64) @@ -248,6 +281,7 @@ describe('mobile structured send operation journal', () => { now: NOW }) ).resolves.toEqual({ + operationKey: OPERATION_KEY, operationId, retained: true, payloadFingerprint, @@ -255,6 +289,30 @@ describe('mobile structured send operation journal', () => { }) }) + it('replays an entry retained under the alternate key, and says which key matched', async () => { + const operationId = operationIdAt(NOW, 'e') + const alternateKey = 'b'.repeat(64) + await getOrCreatePersistedOperation({ + operationKey: alternateKey, + callerIdentity: CALLER_IDENTITY, + payloadFingerprint: 'd'.repeat(64), + attachmentPaths: [], + createOperationId: () => operationId, + now: NOW + }) + await expect( + getOrCreatePersistedOperation({ + operationKey: OPERATION_KEY, + alternateOperationKey: alternateKey, + callerIdentity: CALLER_IDENTITY, + payloadFingerprint: 'd'.repeat(64), + attachmentPaths: [], + createOperationId: () => operationIdAt(NOW, 'f'), + now: NOW + }) + ).resolves.toMatchObject({ operationKey: alternateKey, operationId, retained: true }) + }) + it('keeps an ambiguous id after the host replay window closes', async () => { const expired = operationIdAt(NOW - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 1, '6') asyncStorage.getItem.mockResolvedValueOnce( diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 89779a2082c..98937ad1bbb 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -107,12 +107,16 @@ async function serialize(action: () => Promise): Promise { export async function getOrCreateMobileStructuredSendOperation(input: { operationKey: string + /** The same intent under its other delivery: a retained entry there is replayed + * as-is, so the key that matched — never a stored field — says how it was sent. */ + alternateOperationKey?: string callerIdentity: string payloadFingerprint: string attachmentPaths: readonly string[] createOperationId: () => string now?: number }): Promise<{ + operationKey: string operationId: string retained: boolean payloadFingerprint: string @@ -123,12 +127,19 @@ export async function getOrCreateMobileStructuredSendOperation(input: { const callerFingerprint = mobileStructuredSendCallerFingerprint(input.callerIdentity) const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) const entries = journal.entries - const existing = entries.find((entry) => entry.operationKey === input.operationKey) + const existing = + entries.find((entry) => entry.operationKey === input.operationKey) ?? + entries.find( + (entry) => + input.alternateOperationKey !== undefined && + entry.operationKey === input.alternateOperationKey + ) if (existing) { if (existing.callerFingerprint !== callerFingerprint) { throw new Error('Structured send caller identity changed') } return { + operationKey: existing.operationKey, operationId: existing.operationId, retained: true, payloadFingerprint: existing.payloadFingerprint, @@ -153,6 +164,7 @@ export async function getOrCreateMobileStructuredSendOperation(input: { }) await writeEntries([...entries, entry]) return { + operationKey: input.operationKey, operationId, retained: false, payloadFingerprint: input.payloadFingerprint, @@ -179,7 +191,9 @@ export async function clearMobileStructuredSendOperation(input: { }) } -/** Reconcile an ack-lost operation once the authoritative journal settles it. */ +/** Reconcile an ack-lost operation once the authoritative journal settles it, or hands it off: + * a submission naming the operation's id as its `queuedMessageId` is that send's queued draft + * going out, in whatever state, so the send reached the host and its id is spent. */ export async function clearMobileStructuredSettledSendOperations(input: { submissions: readonly AgentJournalSubmission[] }): Promise { @@ -190,13 +204,20 @@ export async function clearMobileStructuredSettledSendOperations(input: { : [] ) ) - if (settled.size === 0) { + const handedOff = new Set( + input.submissions.flatMap((submission) => + submission.queuedMessageId !== undefined ? [submission.queuedMessageId] : [] + ) + ) + if (settled.size === 0 && handedOff.size === 0) { return } return serialize(async () => { const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) const entries = journal.entries.filter( - (entry) => !settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) + (entry) => + !settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) && + !handedOff.has(entry.operationId) ) if (entries.length !== journal.entries.length) { await writeEntries(entries) @@ -204,7 +225,57 @@ export async function clearMobileStructuredSettledSendOperations(input: { }) } -/** Test-only: drain in-memory serialization while preserving durable storage. */ +/** + * Spend the ids the host publishes as queued drafts. A draft is named by the + * operation id of the send that created it, so seeing one is the host's own + * receipt of that send — the `queued` answer a lost acknowledgement never + * brought. Without this, a draft another device later withdraws leaves an entry + * that no submission will ever settle. + */ +export async function clearMobileStructuredQueuedSendOperations(input: { + queuedMessageIds: readonly string[] +}): Promise { + if (input.queuedMessageIds.length === 0) { + return + } + const held = new Set(input.queuedMessageIds) + return serialize(async () => { + const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) + const entries = journal.entries.filter((entry) => !held.has(entry.operationId)) + if (entries.length !== journal.entries.length) { + await writeEntries(entries) + } + }) +} + +/** Ids sent past a saved record storage would not clear, keyed by the operation key that record + * matched, for this app run only: a retry of that text replays the same id instead of minting + * another, which could deliver it twice. An entry dies at app restart, when the host answers the + * id as spent, or when a saved record takes the id over; a replay the host keeps keeps it. */ +const bypassedOperationIds = new Map() + +export function bypassedMobileStructuredSendOperationId(operationKey: string): string | undefined { + return bypassedOperationIds.get(operationKey) +} + +export function rememberBypassedMobileStructuredSendOperation( + operationKey: string, + operationId: string +): void { + bypassedOperationIds.set(operationKey, operationId) +} + +export function forgetBypassedMobileStructuredSendOperation( + operationKey: string, + operationId: string +): void { + if (bypassedOperationIds.get(operationKey) === operationId) { + bypassedOperationIds.delete(operationKey) + } +} + +/** Test-only: drain in-memory serialization and bypassed ids while preserving durable storage. */ export function resetMobileStructuredSendOperationJournalForTests(): void { mutations.tail = Promise.resolve() + bypassedOperationIds.clear() } diff --git a/mobile/src/session/mobile-tab-close-selection.test.ts b/mobile/src/session/mobile-tab-close-selection.test.ts index 992e0074884..cac9f181a32 100644 --- a/mobile/src/session/mobile-tab-close-selection.test.ts +++ b/mobile/src/session/mobile-tab-close-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { BULK_TAB_CLOSE_ACTIONS, selectBulkCloseTabs } from './mobile-tab-close-selection' +import { selectBulkCloseTabs } from './mobile-tab-close-selection' const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({ id, @@ -10,13 +10,6 @@ const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({ describe('selectBulkCloseTabs', () => { const tabs = [tab('a'), tab('b'), tab('c'), tab('d')] - it('offers only close-others and close-left actions', () => { - expect(BULK_TAB_CLOSE_ACTIONS).toEqual([ - { mode: 'others', label: 'Close Other Tabs' }, - { mode: 'left', label: 'Close Tabs to the Left' } - ]) - }) - it('selects every tab except the anchor for mode "others"', () => { expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd']) }) diff --git a/mobile/src/session/opened-mobile-session-tab.test.ts b/mobile/src/session/opened-mobile-session-tab.test.ts index dbeed5ed830..4df5bb245f3 100644 --- a/mobile/src/session/opened-mobile-session-tab.test.ts +++ b/mobile/src/session/opened-mobile-session-tab.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import { - activateOpenedMobileSessionTab, activateOpenedSourceControlDiffTab, findOpenedMobileSessionTab, - refreshOpenedMobileSessionTabs, shouldActivateOpenedMobileSessionTab, type OpenedMobileSessionTabCandidate } from './opened-mobile-session-tab' @@ -72,141 +70,6 @@ describe('findOpenedMobileSessionTab', () => { }) }) -describe('refreshOpenedMobileSessionTabs', () => { - it('waits for a current refresh before requesting a post-open snapshot', async () => { - const order: string[] = [] - let resolveCurrentRefresh: () => void = () => {} - const currentRefresh = new Promise((resolve) => { - resolveCurrentRefresh = resolve - }) - - const refresh = refreshOpenedMobileSessionTabs({ - getCurrentRefresh: () => currentRefresh, - refreshSessionTabs: async () => { - order.push('fresh') - } - }) - - await Promise.resolve() - expect(order).toEqual([]) - - order.push('current') - resolveCurrentRefresh() - await refresh - - expect(order).toEqual(['current', 'fresh']) - }) -}) - -describe('activateOpenedMobileSessionTab', () => { - const fileTab = { type: 'file', id: 'file-1', relativePath: 'src/app.ts' } - - function createActivationHarness() { - let tabs: OpenedMobileSessionTabCandidate[] = [] - let activeTabId: string | null = 'terminal-1' - let activated = false - const activationSeq = 1 - let latestActivationSeq = 1 - let activeTerminalHandle: string | null = 'pty-1' - let activeTabType: string | null = 'terminal' - const switched: string[] = [] - return { - setTabs(nextTabs: OpenedMobileSessionTabCandidate[]) { - tabs = nextTabs - }, - markActivated() { - activated = true - }, - supersedeActivation() { - latestActivationSeq += 1 - }, - leaveSourceTerminal() { - activeTerminalHandle = null - activeTabType = 'file' - }, - options(fetchSessionTabs: () => Promise) { - return { - relativePath: 'src/app.ts', - fetchSessionTabs, - getTabs: () => tabs, - getActiveTabId: () => activeTabId, - getActivationState: () => ({ - activated, - activationSeq, - latestActivationSeq, - sourceTerminalHandle: 'pty-1', - activeTerminalHandle, - activeTabType - }), - switchSessionTab: (tab: OpenedMobileSessionTabCandidate) => { - activeTabId = tab.id - activeTabType = tab.type - activeTerminalHandle = null - switched.push(tab.id) - return true - } - } - }, - switched - } - } - - it('refreshes tabs and switches to the opened file tab', async () => { - const harness = createActivationHarness() - - const activated = await activateOpenedMobileSessionTab( - harness.options(async () => { - harness.setTabs([fileTab]) - }) - ) - - expect(activated).toBe(true) - expect(harness.switched).toEqual(['file-1']) - }) - - it('does not switch when a newer tap supersedes this attempt during refresh', async () => { - const harness = createActivationHarness() - - const activated = await activateOpenedMobileSessionTab( - harness.options(async () => { - harness.setTabs([fileTab]) - harness.supersedeActivation() - }) - ) - - expect(activated).toBe(false) - expect(harness.switched).toEqual([]) - }) - - it('does not switch when the user leaves the source terminal during refresh', async () => { - const harness = createActivationHarness() - - const activated = await activateOpenedMobileSessionTab( - harness.options(async () => { - harness.setTabs([fileTab]) - harness.leaveSourceTerminal() - }) - ) - - expect(activated).toBe(false) - expect(harness.switched).toEqual([]) - }) - - it('stops retrying after an earlier attempt already activated the tab', async () => { - const harness = createActivationHarness() - harness.markActivated() - - const activated = await activateOpenedMobileSessionTab( - harness.options(async () => { - harness.setTabs([fileTab]) - }) - ) - - expect(activated).toBe(false) - expect(harness.switched).toEqual([]) - }) -}) - describe('activateOpenedSourceControlDiffTab', () => { const diffTab = { type: 'file', id: 'diff-1', mode: 'diff', relativePath: 'src/app.ts' } diff --git a/mobile/src/session/opened-mobile-session-tab.ts b/mobile/src/session/opened-mobile-session-tab.ts index f76571eceef..874bf12f238 100644 --- a/mobile/src/session/opened-mobile-session-tab.ts +++ b/mobile/src/session/opened-mobile-session-tab.ts @@ -16,30 +16,6 @@ export type OpenedMobileSessionTabActivationState = { activeTabType: string | null } -export type ActivateOpenedMobileSessionTabOptions = { - relativePath: string - fetchSessionTabs: () => Promise - getTabs: () => readonly T[] - getActiveTabId: () => string | null - getActivationState: () => OpenedMobileSessionTabActivationState - switchSessionTab: (tab: T) => boolean -} - -export type RefreshOpenedMobileSessionTabsOptions = { - getCurrentRefresh: () => Promise | null - refreshSessionTabs: () => Promise -} - -export async function refreshOpenedMobileSessionTabs( - options: RefreshOpenedMobileSessionTabsOptions -): Promise { - const currentRefresh = options.getCurrentRefresh() - if (currentRefresh) { - await currentRefresh - } - await options.refreshSessionTabs() -} - export function findOpenedMobileSessionTab( tabs: readonly T[], relativePath: string, @@ -126,23 +102,3 @@ export function shouldActivateOpenedMobileSessionTab( state.activeTerminalHandle === state.sourceTerminalHandle return !state.activated && state.activationSeq === state.latestActivationSeq && sourceStillActive } - -export async function activateOpenedMobileSessionTab( - options: ActivateOpenedMobileSessionTabOptions -): Promise { - if (!shouldActivateOpenedMobileSessionTab(options.getActivationState())) { - return false - } - await options.fetchSessionTabs() - if (!shouldActivateOpenedMobileSessionTab(options.getActivationState())) { - return false - } - const opened = findOpenedMobileSessionTab(options.getTabs(), options.relativePath) - if (!opened) { - return false - } - if (options.getActiveTabId() === opened.id) { - return true - } - return options.switchSessionTab(opened) -} diff --git a/mobile/src/session/quick-commands-tab-stability-source.test.ts b/mobile/src/session/quick-commands-tab-stability-source.test.ts deleted file mode 100644 index 9c744a5f982..00000000000 --- a/mobile/src/session/quick-commands-tab-stability-source.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' - -const sourcePath = './MobileSessionHeader.tsx' -const source = readMobileSessionRouteSource(sourcePath) -const sheetsSource = readMobileSessionRouteSource('./MobileSessionSheets.tsx') -const sourceFile = ts.createSourceFile( - sourcePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX -) - -function findQuickCommandsTabButtons(): ts.JsxSelfClosingElement[] { - const matches: ts.JsxSelfClosingElement[] = [] - - function visit(node: ts.Node): void { - if ( - ts.isJsxSelfClosingElement(node) && - node.tagName.getText(sourceFile) === 'QuickCommandsTabButton' - ) { - matches.push(node) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return matches -} - -function getQuickCommandsTabSource(): string { - const start = source.indexOf('accessibilityLabel="New tab"') - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('', start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('quick-commands tab stability', () => { - it('keeps the button mounted while preserving the capability gate', () => { - const tabSource = getQuickCommandsTabSource() - const buttons = findQuickCommandsTabButtons() - - expect(buttons).toHaveLength(1) - const tabBar = buttons[0].parent - expect(ts.isJsxElement(tabBar)).toBe(true) - if (!ts.isJsxElement(tabBar)) { - return - } - expect(tabBar.openingElement.tagName.getText(sourceFile)).toBe('View') - const style = tabBar.openingElement.attributes.properties.find( - (attribute): attribute is ts.JsxAttribute => - ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'style' - ) - expect(style?.initializer?.getText(sourceFile)).toBe('{styles.tabBar}') - expect(tabSource).toContain('if (quickCommandsSupported === true)') - expect(tabSource).toContain('setShowQuickCommands(true)') - expect(tabSource).toContain('Desktop update required for quick commands') - expect(tabSource).toContain('Checking desktop capabilities — try again in a moment') - }) - - it('only presents the sheet after support is confirmed', () => { - expect(sheetsSource).toContain('visible={showQuickCommands && quickCommandsSupported === true}') - }) -}) diff --git a/mobile/src/session/session-router-seam-census.test.ts b/mobile/src/session/session-router-seam-census.test.ts deleted file mode 100644 index 9f73a5434ca..00000000000 --- a/mobile/src/session/session-router-seam-census.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - expoRouterValueImports, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const SESSION_ROOT = import.meta.dirname - -/** - * Which modules here hold a router, so the census cannot pass by seeing nothing. - * - * Four, and each for a different target. The foundation hook bounces a deleted workspace back to - * its host; the file-tap handlers push a preview route from a terminal link or a chat path; the - * notification hook consumes a pane tap by rewriting this route's own params; the review route - * body replaces to the session screen and pops the stack behind it. Only the first two and the - * last can leave the page, which is the whole reason the third is in the list anyway — a - * `setParams` on expo-router's router and a `setParams` on the handoff's are the same call, and - * listing it here is what stops someone later giving it back its own `useRouter` because "it never - * navigates". - */ -const ROUTER_HOLDERS = [ - 'MobileDiffReviewRouteScreen.tsx', - 'use-mobile-file-tap-handlers.ts', - 'use-mobile-session-foundation.ts', - 'use-notification-pane-navigation.ts' -] - -/** - * The expo-router names this domain may still import, and why each one is not a router. - * - * `useFocusEffect` reads whether this screen is the focused one in the document's own stack and - * `useLocalSearchParams` reads the params of the route already mounted. Neither takes a target, so - * neither can put a screen in front of the page; both are the page's own router answering about the - * page's own route, which is exactly what it is for. - * - * A closed list rather than a ban on `useRouter`: the hazard is anything that navigates, and - * expo-router exports a module-singleton `router` that does it from a plain function. A rule written - * against the one spelling would have read that as clean. - */ -const NON_NAVIGATING_ROUTER_NAMES = ['useFocusEffect', 'useLocalSearchParams'] - -describe('the session domain reaches the router through the handoff seam', () => { - const files = productFiles(SESSION_ROOT) - - it('walks the modules it is written against', () => { - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - expect(files.length).toBeGreaterThan(200) - }) - - it('imports nothing from expo-router that can navigate', () => { - const offenders = files - .map((name) => ({ - name, - imported: expoRouterValueImports(parse(SESSION_ROOT, name)).filter( - (imported) => !NON_NAVIGATING_ROUTER_NAMES.includes(imported) - ) - })) - .filter((entry) => entry.imported.length > 0) - .map((entry) => `${entry.name} (${entry.imported.join(', ')})`) - expect(offenders).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen that holds one', () => { - expect(files.filter((name) => callsRouteHandoff(parse(SESSION_ROOT, name))).sort()).toEqual( - [...ROUTER_HOLDERS].sort() - ) - }) - - it('still reaches expo-router for the two names that answer about its own route', () => { - // The completeness half: the rule above also passes over a domain that imports nothing at all, - // which is what it would read as if someone moved these hooks and left the list behind. - const imported = new Set( - files.flatMap((name) => expoRouterValueImports(parse(SESSION_ROOT, name))) - ) - expect([...imported].sort()).toEqual([...NON_NAVIGATING_ROUTER_NAMES].sort()) - }) -}) diff --git a/mobile/src/session/terminal-field-submit-binding-wiring.test.ts b/mobile/src/session/terminal-field-submit-binding-wiring.test.ts deleted file mode 100644 index 25a4761af85..00000000000 --- a/mobile/src/session/terminal-field-submit-binding-wiring.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' - -const sendActionsSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-send-actions.ts' -) - -const BINDING_CALL = 'useTerminalTextFieldSubmitBinding(' - -/** The handler each field hands its submit binding, in source order. */ -function readBoundSubmitNames(source: string): string[] { - const names: string[] = [] - let at = source.indexOf(BINDING_CALL) - while (at >= 0) { - const args = source.slice(at + BINDING_CALL.length, source.indexOf(')', at)) - const handler = args.split(',')[1]?.trim() - if (handler) { - names.push(handler) - } - at = source.indexOf(BINDING_CALL, at + 1) - } - return names -} - -/** - * The rule: a handler bound through `useTerminalTextFieldSubmitBinding` must not be frozen on an - * empty dependency list. - * - * `handleSend` is a per-render function whose guard reads `client`, `activeHandle` and `canSend`, - * all absent on the first render, so a submit memoized on `[]` is one closure for the life of the - * component and the field's line-break submit can never pass that guard. The field's own - * `onSubmitEditing` prop keeps working, which is what hid it. - * - * Why a source rule and not a render: every check that mounts its own submit writes the identity - * under test itself, so the render rig's probe route could catch neither the defect nor the fix. - * `terminal-field-submit-binding-send.test.tsx` reads the behaviour instead and catches spellings - * this cannot; this one is the cheap guard beside it. - */ -describe('the submit handlers the terminal fields bind', () => { - it('names one handler per bound field', () => { - expect(readBoundSubmitNames(sendActionsSource)).toEqual([ - 'submitLiveInput', - 'submitBufferedDraft' - ]) - }) - - it('freezes none of them on an empty dependency list', () => { - for (const name of readBoundSubmitNames(sendActionsSource)) { - const declaredAt = sendActionsSource.indexOf(`const ${name} = `) - expect(declaredAt).toBeGreaterThanOrEqual(0) - const boundAt = sendActionsSource.indexOf(`${BINDING_CALL}`, declaredAt) - expect(boundAt).toBeGreaterThan(declaredAt) - expect(sendActionsSource.slice(declaredAt, boundAt)).not.toContain('}, [])') - } - }) - - // The other half of the rule: a handler that is memoized must name what it reads, so the closure - // moves when the send guard's inputs do. - it('gives the live input submit the dependencies its closure reads', () => { - const declaredAt = sendActionsSource.indexOf('const submitLiveInput = ') - const boundAt = sendActionsSource.indexOf(BINDING_CALL, declaredAt) - const slice = sendActionsSource.slice(declaredAt, boundAt) - expect(slice).toContain('handleLiveInputSubmit') - expect(slice).toContain('activeSessionTab') - }) -}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 0bffa3039f2..cff91f7ab8b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -90,6 +90,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ useMobileStructuredAgentSession: () => ({ session: structuredSessionState, ...structuredActivity, + queued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn() }, sendWithOutcome: structuredSendWithOutcome, cancel: structuredCancel, cancelPrompt: structuredCancelPrompt, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c14c3e1251c..3b6c1e23818 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -82,6 +82,10 @@ export function useMobileNativeChatController(args: { nativeChatTranscriptIsLocalReadable }) + // The lane runs before the drafts hook (fixed hook order); Edit's composer + // append reaches the drafts state through this ref, set below once they exist. + // Until the drafts mount, nothing is copied, so Edit deletes nothing. + const appendComposerTextRef = useRef<(text: string) => boolean>(() => false) const { structuredSession: structuredNativeChat, session: nativeChatSession } = useMobileNativeChatSessionLane({ client, @@ -95,12 +99,15 @@ export function useMobileNativeChatController(args: { enabled: showNativeChat, connState, hostSupport: agentSessionHostSupport, - onSendError + appendComposerTextRef, + onSendError, + onActionResolved: onSendResolved }) const { composerText: chatComposerText, setComposerText: setChatComposerText, getComposerEditGeneration: getChatComposerEditGeneration, + appendComposerText, pending: chatPending, imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, @@ -123,7 +130,8 @@ export function useMobileNativeChatController(args: { // terminal view would permanently decline the prefill. chatActive: showNativeChat, transcriptLoading: nativeChatSession.transcriptLoading, - transcriptSettled: nativeChatSession.status === 'ready' + transcriptSettled: nativeChatSession.status === 'ready', + queuedCards: structuredNativeChat.queued.cards }) // Deliberately not gated on the chat view being visible: the streaming gate @@ -257,7 +265,8 @@ export function useMobileNativeChatController(args: { }) useLayoutEffect(() => { recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand - }, [recordNativeChatSessionOptionCommand]) + appendComposerTextRef.current = appendComposerText + }, [appendComposerText, recordNativeChatSessionOptionCommand]) // Card actions retire the route's held failure banner too, not just sends. const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) @@ -288,10 +297,7 @@ export function useMobileNativeChatController(args: { nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, - nativeChatActiveTurnOpenedBy: activeChatStructured - ? structuredNativeChat.activeTurnOpenedBy - : null, - nativeChatTurnKeysByItemId: activeChatStructured ? structuredNativeChat.turnKeysByItemId : null, + nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, @@ -312,6 +318,8 @@ export function useMobileNativeChatController(args: { handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined, handleNativeChatRespondPermission: respond, handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, + // The inactive lane's session is starved of identity, so its cards stay empty. + nativeChatQueued: structuredNativeChat.queued, nativeChatFilePaths, loadNativeChatFiles, handleNativeChatQuestionAnswer: activeChatStructured diff --git a/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts new file mode 100644 index 00000000000..77c95595ded --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts @@ -0,0 +1,112 @@ +// An ack-lost send the host took as a queued draft shows as a card, not in the +// transcript, until the turn ends — so the unconfirmed hold must count the card +// as delivery, or the user is told to "check chat" about a message on screen. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type QueuedCard = { messageId: string; text: string } + +describe('useMobileNativeChatDrafts unconfirmed hold with queued cards', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + vi.useRealTimers() + }) + + function Harness({ queuedCards }: { queuedCards: QueuedCard[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages: [], + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards + }) + return null + } + + async function render(queuedCards: QueuedCard[]): Promise { + await act(async () => { + if (renderer) { + renderer.update(createElement(Harness, { queuedCards })) + } else { + renderer = create(createElement(Harness, { queuedCards })) + } + }) + } + + /** Holds a lost send; the returned call runs out its deadline and hands back the warning spy. */ + function holdLostSend(text: string): () => ReturnType { + const onUnconfirmed = vi.fn() + const origin = state?.captureSendOrigin(text) + if (!origin) { + throw new Error('no send origin') + } + act(() => state?.holdUnconfirmedSend(origin, text, onUnconfirmed)) + return () => { + act(() => { + vi.advanceTimersByTime(30_000) + }) + return onUnconfirmed + } + } + + it("reports whether a queued card's Edit copy landed, so Edit never deletes text it did not keep", async () => { + await render([]) + let copied: boolean | undefined + act(() => { + copied = state?.appendComposerText('') + }) + expect(copied).toBe(false) + act(() => { + copied = state?.appendComposerText('edited text') + }) + expect(copied).toBe(true) + expect(state?.composerText).toBe('edited text') + }) + + it('stays quiet when the lost send appears as a queued card', async () => { + await render([]) + const settle = holdLostSend('after this turn') + await render([{ messageId: 'draft-1', text: 'after this turn' }]) + expect(settle()).not.toHaveBeenCalled() + }) + + it('stays quiet when the card was published before the lost answer returned', async () => { + await render([]) + const origin = state?.captureSendOrigin('raced') + await render([{ messageId: 'draft-1', text: 'raced' }]) + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'raced', onUnconfirmed) + } + }) + expect(vi.getTimerCount()).toBe(0) + expect(onUnconfirmed).not.toHaveBeenCalled() + }) + + it('does not confirm against an identical card already on screen at send time', async () => { + await render([{ messageId: 'earlier', text: 'same words' }]) + const settle = holdLostSend('same words') + await render([ + { messageId: 'earlier', text: 'same words' }, + { messageId: 'x', text: 'other' } + ]) + expect(settle()).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a50ccf4c923..f17e1826a67 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,14 +1,21 @@ -import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countUserTextOccurrences, findLandedImagePreviewEchoes, - findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, - normalizeReconcileText, - type UnconfirmedSend + normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import { useMobileNativeChatUnconfirmedSends } from './use-mobile-native-chat-unconfirmed-sends' import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' import { @@ -29,9 +36,6 @@ export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] const NO_IMAGE_PREVIEWS: Record = {} -// Ack-lost sends wait for a transcript echo before surfacing as unconfirmed. -const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 - export function useMobileNativeChatDrafts(args: { hostId: string worktreeId: string @@ -52,9 +56,14 @@ export function useMobileNativeChatDrafts(args: { * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ transcriptSettled: boolean + /** The active pane's host-held queued-draft cards (structured lane only). */ + queuedCards?: readonly { messageId: string; text: string }[] }): { composerText: string setComposerText: Dispatch> + /** Append after existing typing (newline-joined); a queued card's Edit copy lands here. + * False when nothing was appended: no active draft, or no text. */ + appendComposerText: (text: string) => boolean getComposerEditGeneration: () => number pending: MobileNativeChatPendingMessage[] /** Phone-local previews rebound to the transcript message that replaced the @@ -87,7 +96,8 @@ export function useMobileNativeChatDrafts(args: { launchDraftCreatedAt, chatActive = true, transcriptLoading, - transcriptSettled + transcriptSettled, + queuedCards } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -105,11 +115,11 @@ export function useMobileNativeChatDrafts(args: { const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations()) const messagesRef = useRef(messages) messagesRef.current = messages - const activeDraftKeyRef = useRef(draftKey) - activeDraftKeyRef.current = draftKey - const activePendingKeyRef = useRef(pendingKey) - activePendingKeyRef.current = pendingKey - const mountedRef = useRef(false) + const queuedCardsRef = useRef(queuedCards) + // Read only by captureSendOrigin, which runs from a send after this commit. + useLayoutEffect(() => { + queuedCardsRef.current = queuedCards + }, [queuedCards]) const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, @@ -135,6 +145,23 @@ export function useMobileNativeChatDrafts(args: { }, [draftKey] ) + // Copied queued-card text goes after whatever is there, so newer typing is preserved. + const appendComposerText = useCallback( + (text: string): boolean => { + if (!draftKey || text.length === 0) { + return false + } + draftEditGenerationsRef.current.advance(draftKey) + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = current.length === 0 ? text : `${current}\n${text}` + return { ...previous, [draftKey]: next } + }) + return true + }, + [draftKey] + ) + const captureSendOrigin = useCallback( (text: string) => { if (!draftKey) { @@ -151,7 +178,10 @@ export function useMobileNativeChatDrafts(args: { // Only a settled read makes this a boundary. Anything else — hydrating, // or a read that failed — hands back an empty list that reads as "the // conversation was empty", which lets any row claim this send later. - baselineResolved: transcriptSettled + baselineResolved: transcriptSettled, + ...(queuedCardsRef.current?.length + ? { baselineQueuedMessageIds: queuedCardsRef.current.map((card) => card.messageId) } + : {}) } }, [draftKey, pendingKey, transcriptSettled] @@ -200,74 +230,12 @@ export function useMobileNativeChatDrafts(args: { [] ) - // Why: a relay drop mid-send loses only the ack in the common case — the - // desktop already delivered the message. Hold the send instead of claiming - // failure (which baits a duplicate): stay quiet when the transcript echo - // lands, and surface the uncertainty if the deadline passes without one. - // The composer was already cleared at send time, so this never touches drafts. - const unconfirmedRef = useRef([]) - const holdUnconfirmedSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { - if (!mountedRef.current) { - return - } - const isActiveTranscript = - activeDraftKeyRef.current === origin.draftKey && - (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) - const entry: UnconfirmedSend = { - draftKey: origin.draftKey, - pendingKey: origin.pendingKey, - text, - normalizedText: origin.normalizedText, - baselineTailMessageId: origin.baselineTailMessageId, - deadline: null - } - // Why: the transcript event can beat the lost RPC acknowledgement. - if ( - isActiveTranscript && - findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 - ) { - return - } - entry.deadline = setTimeout(() => { - unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) - onUnconfirmed() - }, UNCONFIRMED_SEND_DEADLINE_MS) - unconfirmedRef.current = [...unconfirmedRef.current, entry] - }, - [] - ) - - useEffect(() => { - if (!draftKey || unconfirmedRef.current.length === 0) { - return - } - const relevant = unconfirmedRef.current.filter( - (entry) => - entry.draftKey === draftKey && - (entry.pendingKey === null || entry.pendingKey === pendingKey) - ) - const landed = findLandedUnconfirmedSends(messages, relevant) - if (landed.length === 0) { - return - } - const landedSet = new Set(landed) - unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) - for (const entry of landed) { - clearTimeout(entry.deadline ?? undefined) - } - }, [messages, draftKey, pendingKey]) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - for (const entry of unconfirmedRef.current) { - clearTimeout(entry.deadline ?? undefined) - } - unconfirmedRef.current = [] - } - }, []) + const { holdUnconfirmedSend } = useMobileNativeChatUnconfirmedSends({ + draftKey, + pendingKey, + messages, + ...(queuedCards ? { queuedCards } : {}) + }) const waitingForSession = draftKey ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) @@ -336,6 +304,7 @@ export function useMobileNativeChatDrafts(args: { return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, + appendComposerText, getComposerEditGeneration: draftEditGenerationsRef.current.readComposer, pending, imagePreviewsByMessageId: pendingKey diff --git a/mobile/src/session/use-mobile-native-chat-queued-edit-focus.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.test.tsx new file mode 100644 index 00000000000..c2ec28b3651 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.test.tsx @@ -0,0 +1,74 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatQueuedEditFocus } from './use-mobile-native-chat-queued-edit-focus' +import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls' + +type Focus = ReturnType + +describe('useMobileNativeChatQueuedEditFocus', () => { + let renderer: ReactTestRenderer | null = null + let latest: Focus | null = null + + function Harness({ onEdit }: { onEdit?: MobileQueuedMessageEdit }): null { + latest = useMobileNativeChatQueuedEditFocus(onEdit) + return null + } + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + latest = null + vi.unstubAllGlobals() + }) + + it('focuses the composer after Edit copies a queued message into it', async () => { + const onEdit = vi.fn(async (_messageId, onCopied) => { + onCopied?.() + return false + }) + await act(async () => { + renderer = create(createElement(Harness, { onEdit })) + }) + const focus = vi.fn() + const inputRef: { current: unknown } = latest!.composerInputRef + inputRef.current = { focus } + let edited: boolean | undefined + await act(async () => { + edited = await latest!.editQueuedMessage!('queued-1') + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(onEdit).toHaveBeenCalledWith('queued-1', expect.any(Function)) + // A failed Delete still leaves the copied text in the composer, so focus regardless. + expect(focus).toHaveBeenCalledTimes(1) + expect(edited).toBe(false) + }) + + it('leaves focus alone when Edit copied nothing', async () => { + const onEdit = vi.fn(async () => false) + await act(async () => { + renderer = create(createElement(Harness, { onEdit })) + }) + const focus = vi.fn() + const inputRef: { current: unknown } = latest!.composerInputRef + inputRef.current = { focus } + await act(async () => { + await latest!.editQueuedMessage!('queued-1') + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(focus).not.toHaveBeenCalled() + }) + + it('passes no Edit through when the cards offer none', async () => { + await act(async () => { + renderer = create(createElement(Harness, {})) + }) + expect(latest!.editQueuedMessage).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-queued-edit-focus.ts b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.ts new file mode 100644 index 00000000000..574316bccfa --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.ts @@ -0,0 +1,24 @@ +import { useMemo, useRef, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls' + +/** Wraps a queued card's Edit so the composer it fills takes focus, and typing continues + * without another tap. An Edit that copied nothing leaves focus where it is. */ +export function useMobileNativeChatQueuedEditFocus(onEdit: MobileQueuedMessageEdit | undefined): { + composerInputRef: RefObject + editQueuedMessage: MobileQueuedMessageEdit | undefined +} { + const composerInputRef = useRef(null) + const editQueuedMessage = useMemo( + () => + onEdit + ? (messageId) => + onEdit(messageId, () => + // Next frame, so the copied text has landed in the field before it takes focus. + requestAnimationFrame(() => composerInputRef.current?.focus()) + ) + : undefined, + [onEdit] + ) + return { composerInputRef, editQueuedMessage } +} diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx new file mode 100644 index 00000000000..002a567548f --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -0,0 +1,79 @@ +// The queued-card slot keeps one card list per conversation, so an action still in flight in one +// never disables the same control in another. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatQueuedSlot } from './use-mobile-native-chat-queued-slot' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + CornerDownRight: 'CornerDownRight', + ListEnd: 'ListEnd', + MoreHorizontal: 'MoreHorizontal', + Pause: 'Pause', + Pencil: 'Pencil', + Play: 'Play', + Send: 'Send', + Trash2: 'Trash2' +})) + +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) + +const CARD = { + messageId: 'waiting-1', + text: 'next', + state: 'waiting' as const, + paused: false, + needsAttention: false, + caption: null +} + +function Slot({ + sessionKey, + onResume +}: { + sessionKey: string + onResume: () => Promise +}): React.JSX.Element { + const slot = useMobileNativeChatQueuedSlot({ + cards: [CARD], + pause: { reason: 'stopped' }, + onResume, + sessionKey + }) + return createElement('View', null, slot.cards) +} + +describe('useMobileNativeChatQueuedSlot', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it("never lets one conversation's pending Resume disable another's", async () => { + const pending = vi.fn(() => new Promise(() => undefined)) + const mounted = create(createElement('View')) + renderer = mounted + const resume = () => + mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) + await act(async () => { + mounted.update(createElement(Slot, { sessionKey: 'session-a', onResume: pending })) + }) + await act(async () => resume().props.onPress()) + expect(resume().props.disabled).toBe(true) + await act(async () => { + mounted.update(createElement(Slot, { sessionKey: 'session-b', onResume: pending })) + }) + expect(resume().props.disabled).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts new file mode 100644 index 00000000000..aac805a5c20 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.ts @@ -0,0 +1,50 @@ +// The queued-draft cards' place in the chat view, built outside it: the cards to show between the +// transcript and the composer, and the composer ref their Edit focuses. + +import { createElement, useMemo, type ReactNode, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + MobileNativeChatQueuedMessages, + type MobileNativeChatQueuedMessagesProps +} from './MobileNativeChatQueuedMessages' +import { useMobileNativeChatQueuedEditFocus } from './use-mobile-native-chat-queued-edit-focus' + +export type MobileNativeChatQueuedSlot = { + cards?: ReactNode + composerInputRef?: RefObject +} + +export type MobileQueuedSlotProps = { + /** Host-held queued drafts, rendered as cards between transcript and composer. */ + queuedSlot?: MobileNativeChatQueuedSlot +} + +/** A view given no slot shows no cards; one stable object, so the default never re-renders. */ +export const NO_QUEUED_SLOT: MobileNativeChatQueuedSlot = {} + +export function useMobileNativeChatQueuedSlot( + queuedMessages: MobileNativeChatQueuedMessagesProps & { + /** One card list per conversation: a pending action in one never disables another's. */ + sessionKey: string + } +): MobileNativeChatQueuedSlot { + const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( + queuedMessages.onEdit + ) + const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages + return useMemo( + () => ({ + cards: createElement(MobileNativeChatQueuedMessages, { + key: sessionKey, + cards, + onSend, + onDelete, + onEdit: editQueuedMessage, + pause, + onResume + }), + composerInputRef + }), + [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index 147cf017ac0..c34f1995190 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' @@ -17,9 +18,11 @@ export function useMobileNativeChatSessionLane({ sourceIdentity, callerIdentity, hostSupport, + appendComposerTextRef, enabled, connState, - onSendError + onSendError, + onActionResolved }: { client: RpcClient | null structured: boolean @@ -32,13 +35,22 @@ export function useMobileNativeChatSessionLane({ sourceIdentity: Parameters[0]['sourceIdentity'] callerIdentity: string hostSupport: StructuredAgentSessionHostSupport | null + /** The active pane's live composer; a queued card's Edit copies through it. + * A ref because the drafts (and their append) mount after this lane. */ + appendComposerTextRef: { readonly current: (text: string) => boolean } enabled: boolean connState: ConnectionState onSendError: (message: string) => void + /** Called on any accepted queued-card action; retires the route's failure banner. */ + onActionResolved?: () => void }): { structuredSession: ReturnType session: ReturnType } { + const appendComposerText = useCallback( + (text: string) => appendComposerTextRef.current(text), + [appendComposerTextRef] + ) const bridgeSession = useMobileNativeChatSession({ client, sourceIdentity, @@ -52,12 +64,14 @@ export function useMobileNativeChatSessionLane({ sourceIdentity, callerIdentity, hostSupport, + appendComposerText, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. connected: connState === 'connected', agent: structured ? agent : null, - onSendError + onSendError, + ...(onActionResolved ? { onActionResolved } : {}) }) return { structuredSession, diff --git a/mobile/src/session/use-mobile-native-chat-session-stream-end.test.ts b/mobile/src/session/use-mobile-native-chat-session-stream-end.test.ts new file mode 100644 index 00000000000..2ff419fab5f --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-stream-end.test.ts @@ -0,0 +1,417 @@ +import { createElement, Fragment, type ReactElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MobileRelayRpcStreams } from '../transport/mobile-relay-rpc-streams' +import { RpcClientStreamRegistry } from '../transport/rpc-client-stream-registry' +import type { RpcResponse } from '../transport/types' +import { + useMobileNativeChatSession, + type MobileNativeChatSession +} from './use-mobile-native-chat-session' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +function streamed(id: string, result: unknown): RpcResponse { + return { id, ok: true, streaming: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +type SentRequest = { id: string; method: string; params?: unknown } + +function readSentRequest(value: unknown): SentRequest { + if ( + typeof value !== 'object' || + value === null || + !('id' in value) || + !('method' in value) || + typeof value.id !== 'string' || + typeof value.method !== 'string' + ) { + throw new Error('unexpected request shape') + } + return { + id: value.id, + method: value.method, + params: 'params' in value ? value.params : undefined + } +} + +function testClient(subscribe: RpcClient['subscribe']): RpcClient { + return { + sendRequest: () => new Promise(() => {}), + subscribe, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } +} + +/** A client whose host is the test: every subscribe's listener is kept, in order. */ +function listenerClient(): { + client: RpcClient + subscribe: ReturnType> + listeners: ((frame: unknown) => void)[] +} { + const listeners: ((frame: unknown) => void)[] = [] + const subscribe = vi.fn((_method, _params, onData) => { + listeners.push(onData) + return () => {} + }) + return { client: testClient(subscribe), subscribe, listeners } +} + +/** A host that, like the runtime, keys each feed by its token: a same-token subscribe ends the feed + * it replaces, and an unsubscribe ends whatever holds the token. Frames queue until `deliver`, so + * each end lands a round trip after the request that caused it. */ +function evictingHost(): { + client: RpcClient + subscribe: ReturnType> + deliver: () => Promise +} { + const live = new Map void>() + let queued: (() => void)[] = [] + const subscribe = vi.fn((_method, params, onData) => { + const token = + typeof params === 'object' && params !== null && 'subscriptionId' in params + ? String(params.subscriptionId) + : '' + const replaced = live.get(token) + live.set(token, onData) + if (replaced) { + queued.push(() => replaced({ type: 'end' })) + } + queued.push(() => onData({ type: 'snapshot', messages: [message('a')], hasMore: false })) + return () => { + const holder = live.get(token) + live.delete(token) + if (holder && holder !== onData) { + queued.push(() => holder({ type: 'end' })) + } + } + }) + const deliver = async (): Promise => { + // Bounded so a same-token fight between screens shows up as an ended feed, not a hang. + for (let round = 0; round < 10 && queued.length > 0; round += 1) { + const frames = queued + queued = [] + await act(async () => { + for (const frame of frames) { + frame() + } + }) + } + } + return { client: testClient(subscribe), subscribe, deliver } +} + +/** A paired host as the phone's real stream layer sees it: requests out, replies in. */ +type TransportRig = { + client: RpcClient + sent: SentRequest[] + reply: (response: RpcResponse) => void + /** Drops and re-authenticates the socket; only the direct transport replays its streams. */ + reconnect?: () => void +} + +function directRig(): TransportRig { + const sent: SentRequest[] = [] + let nextId = 0 + const registry = new RpcClientStreamRegistry({ + nextId: () => `rpc-${++nextId}`, + deviceToken: 'device-token', + getState: () => 'connected', + sendEncrypted: (request) => { + sent.push(readSentRequest(request)) + return true + } + }) + return { + client: testClient(registry.subscribe.bind(registry)), + sent, + reply: (response) => registry.handleResponse(response), + reconnect: () => { + registry.markForReplay() + registry.replayAfterAuthentication() + } + } +} + +function relayRig(): TransportRig { + const sent: SentRequest[] = [] + let nextId = 0 + const streams = new MobileRelayRpcStreams({ + nextId: () => `relay-${++nextId}`, + sendFrame: (request) => { + sent.push(request) + return true + }, + waitForConnected: async () => {} + }) + return { + client: testClient(streams.subscribe.bind(streams)), + sent, + reply: (response) => streams.handleResponse(response) + } +} + +describe('useMobileNativeChatSession host-ended stream', () => { + let renderer: ReactTestRenderer | null = null + let state: MobileNativeChatSession | null = null + + beforeEach(() => { + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ + client, + agent = 'claude', + sessionId = 'session' + }: { + client: RpcClient + agent?: string | null + sessionId?: string + }): null { + state = useMobileNativeChatSession({ + client, + sourceIdentity: 'host-a\0workspace-a', + agent, + sessionId, + transcriptPath: null + }) + return null + } + + async function render(props: { + client: RpcClient + agent?: string | null + sessionId?: string + }): Promise { + await act(async () => { + if (renderer) { + renderer.update(createElement(Harness, props)) + } else { + renderer = create(createElement(Harness, props)) + } + }) + } + + function sentWith(rig: TransportRig, method: string): SentRequest[] { + return rig.sent.filter((request) => request.method === method) + } + + function tokenOf(request: SentRequest): unknown { + const params = request.params + return typeof params === 'object' && params !== null && 'subscriptionId' in params + ? params.subscriptionId + : undefined + } + + it.each([ + ['direct', directRig], + ['relay', relayRig] + ])( + 'settles a chat feed the host ended on a %s connection as an error, not ready', + async (_label, makeRig) => { + const rig = makeRig() + await render({ client: rig.client }) + const [first] = sentWith(rig, 'nativeChat.subscribe') + await act(async () => { + rig.reply( + streamed(first!.id, { type: 'snapshot', messages: [message('a')], hasMore: false }) + ) + }) + expect(state?.status).toBe('ready') + + await act(async () => { + rig.reply(streamed(first!.id, { type: 'end' })) + }) + + expect(state?.status).toBe('error') + expect(state?.transcriptLoading).toBe(false) + // The conversation stays on screen; nothing reopens behind the user's back. + expect(state?.messages.map((entry) => entry.id)).toEqual(['a']) + expect(sentWith(rig, 'nativeChat.subscribe')).toHaveLength(1) + } + ) + + it.each([ + ['direct', directRig], + ['relay', relayRig] + ])( + 'leaving and re-entering an ended %s chat resubscribes with a fresh token', + async (_label, makeRig) => { + const rig = makeRig() + await render({ client: rig.client }) + const [first] = sentWith(rig, 'nativeChat.subscribe') + await act(async () => { + rig.reply(streamed(first!.id, { type: 'end' })) + }) + expect(state?.status).toBe('error') + + // Toggle to the terminal view and back. + await render({ client: rig.client, agent: null }) + await render({ client: rig.client }) + const subscribes = sentWith(rig, 'nativeChat.subscribe') + expect(subscribes).toHaveLength(2) + expect(tokenOf(subscribes[1]!)).toMatch(/^claude:session:./) + expect(tokenOf(subscribes[1]!)).not.toBe(tokenOf(first!)) + // The ended feed is already gone on the host, so leaving it names nothing. + expect(sentWith(rig, 'nativeChat.unsubscribe')).toEqual([]) + expect(state?.status).toBe('loading') + + await act(async () => { + rig.reply( + streamed(subscribes[1]!.id, { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: false + }) + ) + }) + expect(state?.status).toBe('ready') + expect(state?.messages.map((entry) => entry.id)).toEqual(['a', 'b']) + } + ) + + it('ignores an end on a stream it already closed itself', async () => { + const { client, subscribe, listeners } = listenerClient() + await render({ client }) + await render({ client, sessionId: 'other' }) + expect(subscribe).toHaveBeenCalledTimes(2) + await act(async () => + listeners[1]!({ type: 'snapshot', messages: [message('a')], hasMore: false }) + ) + + await act(async () => listeners[0]!({ type: 'end' })) + + expect(state?.status).toBe('ready') + expect(subscribe).toHaveBeenCalledTimes(2) + }) +}) + +describe('useMobileNativeChatSession feeds across stacked screens', () => { + let renderer: ReactTestRenderer | null = null + const states = new Map() + + beforeEach(() => { + states.clear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Screen({ name, client }: { name: string; client: RpcClient }): null { + states.set( + name, + useMobileNativeChatSession({ + client, + sourceIdentity: 'host-a\0workspace-a', + agent: 'claude', + sessionId: 'session', + transcriptPath: null + }) + ) + return null + } + + function Stack({ client, screens }: { client: RpcClient; screens: string[] }): ReactElement { + return createElement( + Fragment, + null, + ...screens.map((name) => createElement(Screen, { key: name, name, client })) + ) + } + + async function render(client: RpcClient, screens: string[]): Promise { + await act(async () => { + if (renderer) { + renderer.update(createElement(Stack, { client, screens })) + } else { + renderer = create(createElement(Stack, { client, screens })) + } + }) + } + + function tokenOf(request: SentRequest | unknown[]): unknown { + const params = Array.isArray(request) ? request[1] : request.params + return typeof params === 'object' && params !== null && 'subscriptionId' in params + ? params.subscriptionId + : undefined + } + + it('keeps both feeds live when a pushed screen opens the same chat, and after it pops', async () => { + const host = evictingHost() + await render(host.client, ['under']) + await host.deliver() + expect(states.get('under')?.status).toBe('ready') + + // Resuming from agent history pushes a second screen for the same chat. + await render(host.client, ['under', 'top']) + await host.deliver() + expect(states.get('under')?.status).toBe('ready') + expect(states.get('top')?.status).toBe('ready') + expect(host.subscribe).toHaveBeenCalledTimes(2) + const [under, top] = host.subscribe.mock.calls + expect(tokenOf(under!)).not.toBe(tokenOf(top!)) + + // Popping the pushed screen releases its own feed only. + await render(host.client, ['under']) + await host.deliver() + expect(host.subscribe).toHaveBeenCalledTimes(2) + expect(states.get('under')?.status).toBe('ready') + }) + + it.each([ + ['direct', directRig], + ['relay', relayRig] + ])( + 'names each %s chat feed by its own token on replay and unsubscribe', + async (_label, makeRig) => { + const rig = makeRig() + await render(rig.client, ['under', 'top']) + const [under, top] = rig.sent.filter((request) => request.method === 'nativeChat.subscribe') + expect(tokenOf(under!)).toMatch(/^claude:session:/) + expect(tokenOf(top!)).toMatch(/^claude:session:/) + expect(tokenOf(under!)).not.toBe(tokenOf(top!)) + + if (rig.reconnect) { + rig.reconnect() + const replayed = rig.sent + .filter((request) => request.method === 'nativeChat.subscribe') + .slice(2) + expect(replayed.map((request) => [request.id, tokenOf(request)])).toEqual([ + [under!.id, tokenOf(under!)], + [top!.id, tokenOf(top!)] + ]) + } + + // The older screen leaves while the newer one stays: its unsubscribe must still be sent. + await render(rig.client, ['top']) + expect( + rig.sent + .filter((request) => request.method === 'nativeChat.unsubscribe') + .map((request) => request.params) + ).toEqual([{ subscriptionId: tokenOf(under!) }]) + } + ) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 537c3b8b8e3..1f8bdae2d48 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -12,6 +12,7 @@ import { applyMobileNativeChatStreamFrame, type MobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' +import { structuredSessionRandomUuid } from './structured-session-operation-id' export type MobileNativeChatStatus = | 'idle' @@ -154,7 +155,12 @@ export function useMobileNativeChatSession(args: { agent, sessionId, limit: limitRef.current, - subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + // Why: a token of its own, so another screen on this chat never evicts this feed on the host. + subscriptionId: buildNativeChatSubscriptionId( + agent, + sessionId, + structuredSessionRandomUuid() + ), capabilities: { transcriptPending: 1 }, ...(transcriptPath ? { transcriptPath } : {}) }, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index ed0b22dfc1f..19f0dfbc0db 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -1,7 +1,14 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' @@ -20,18 +27,16 @@ function Harness({ enabled, isWorking = true, settledTurns, + turnJournal, workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] enabled: boolean isWorking?: boolean settledTurns?: NativeChatSettledTurns + turnJournal?: NativeChatTurnJournal workingStartedAt?: number | null - activeTurnOpenedBy?: string | null - turnKeysByItemId?: ReadonlyMap | null scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -39,9 +44,8 @@ function Harness({ enabled, isWorking, settledTurns, + turnJournal, workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId, scopeKey }) return createElement('result', { disclosure }) @@ -211,199 +215,474 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) - it('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const tool: NativeChatMessage = { - id: 'tool-a', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' + describe('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { + const tool: NativeChatMessage = { + id: 'tool-a', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('A'), tool, userMessage('B')] + const user = ( + id: string, + sequence: number, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: id, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + ...(turnScope ? { turnScope } : {}) + }) + const turn = ( + turnId: string, + sequence: number, + state: 'running' | 'interrupted', + userItemId: string, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: turnId, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'turn', turnId, state, userItemId }, + ...(turnScope ? { turnScope } : {}) + }) + const toolItem = ( + sequence: number, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: 'tool-a', + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'tool-call', + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' + }, + ...(turnScope ? { turnScope } : {}) + }) + const rows = () => { + const disclosure = renderer!.root.findByType('result').props.disclosure + return messages.map((message, index) => disclosure.resolveRow(index, message)) + } + const render = (props: Parameters[0]) => + act(() => { + if (renderer) { + renderer.update(createElement(Harness, props)) + } else { + renderer = create(createElement(Harness, props)) + } + }) + + it("on a host that states each row's turn, the steered message and the work are one live turn", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const t1 = { kind: 'turn', turnItemId: 't1' } as const + const thread = { kind: 'thread' } as const + // B was handed over while A's turn runs, so the host scopes it to that turn. + const whileA = [ + user('A', 1, thread), + turn('t1', 2, 'running', 'A', thread), + toolItem(3, t1), + user('B', 4, t1) + ] + render({ + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { items: whileA, submissions: [] } + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + expect([rowA, rowTool, rowB].map((row) => row.activeTurnIsWorking)).toEqual([ + true, + true, + true + ]) + + // B's own turn opens: A takes the host's settled duration, B counts from A's end. + const whileB = [ + user('A', 1, thread), + turn('t1', 2, 'interrupted', 'A', thread), + toolItem(3, t1), + user('B', 4, t1), + turn('t2', 5, 'running', 'B', thread) + ] + render({ + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal: { items: whileB, submissions: [] } + }) + ;[rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + expect(rowB.activeTurnIsWorking).toBe(true) + expect(rowTool.activeTurnIsWorking).toBe(false) + } finally { + vi.useRealTimers() } - const messages = [userMessage('A'), tool, userMessage('B')] - const rows = () => { + }) + + it("a queued card's Steer joins the running turn: no bar of its own, and live with it", () => { + const t1 = { kind: 'turn', turnItemId: 't1' } as const + const thread = { kind: 'thread' } as const + // Steer hands the draft over under a fresh submission id while A's turn runs, so the host + // scopes the row to that turn; the submission names the card it came from. + const steerId = agentJournalSubmissionKey('hand-off-1') + const steered = [userMessage('A'), tool, userMessage(steerId)] + render({ + messages: steered, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { + items: [ + user('A', 1, thread), + turn('t1', 2, 'running', 'A', thread), + toolItem(3, t1), + user(steerId, 4, t1) + ], + submissions: [ + { + clientMessageId: 'hand-off-1', + queuedMessageId: 'draft-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 4, + resolvedAt: null + } + ] + } + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowA, , rowSteer] = steered.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).not.toBeNull() + expect(rowSteer.turnStatus).toBeNull() + expect(rowSteer.activeTurnIsWorking).toBe(true) + }) + + it('on a host that states no turn, the bar and liveness follow the running record by journal order', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const whileA = [user('A', 1), turn('t1', 2, 'running', 'A'), toolItem(3), user('B', 4)] + render({ + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { items: whileA, submissions: [] } + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + // Liveness follows the owning turn: A's tool row stays live while B waits. + expect(rowTool.activeTurnIsWorking).toBe(true) + expect(rowB.activeTurnIsWorking).toBe(false) + + const whileB = [ + user('A', 1), + turn('t1', 2, 'interrupted', 'A'), + toolItem(3), + user('B', 4), + turn('t2', 5, 'running', 'B') + ] + render({ + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal: { items: whileB, submissions: [] } + }) + ;[rowA, , rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + } finally { + vi.useRealTimers() + } + }) + }) + + /** A journal of `entries` in order, each naming the turn it belongs to (null for none). With + * `statesScope` false the rows carry no scope, as an older host writes them. */ + function journalOf( + entries: readonly [itemId: string, body: AgentJournalItemBody, turnItemId: string | null][], + statesScope: boolean + ): NativeChatTurnJournal { + return { + items: entries.map(([itemId, body, turnItemId], index) => ({ + itemId, + revision: 0, + sequence: index + 1, + observedAt: index + 1, + body, + ...(statesScope + ? { + turnScope: turnItemId + ? { kind: 'turn' as const, turnItemId } + : { kind: 'thread' as const } + } + : {}) + })), + submissions: [] + } + } + const said = (role: 'user' | 'assistant'): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [] + }) + const record = ( + turnId: string, + state: 'running' | 'completed', + userItemId: string + ): AgentJournalItemBody => ({ kind: 'turn', turnId, state, userItemId }) + const hosts = [ + ['states each row’s turn', true], + ['states no scope', false] + ] as const + + it.each(hosts)( + "keeps a running turn's rows live across a mid-turn send the host folded in, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const tool = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + }) + // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. + const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] + const turnJournal = journalOf( + [ + ['A', said('user'), null], + ['tA', record('tA', 'running', 'A'), null], + ['t1', said('assistant'), 'tA'], + ['B', said('user'), 'tA'], + ['t2', said('assistant'), 'tA'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal + }) + ) + }) const disclosure = renderer!.root.findByType('result').props.disclosure - return messages.map((message, index) => disclosure.resolveRow(index, message)) + const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + // The steered bubble shares A's turn but never carries a bar of its own. + expect(rowB.turnStatus).toBeNull() + expect(rowT1.activeTurnIsWorking).toBe(true) + expect(rowT2.activeTurnIsWorking).toBe(true) + } finally { + vi.useRealTimers() } - // B was sent while A's turn runs; the host still names A as the running turn's opener. - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'A' - }) - ) - }) - let [rowA, rowTool, rowB] = rows() - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) - expect(rowB.turnStatus).toBeNull() - // Liveness follows the owning turn: A's tool row stays live while B waits. - expect(rowTool.activeTurnIsWorking).toBe(true) - expect(rowB.activeTurnIsWorking).toBe(false) - - // B's own turn opens: A takes the host's settled duration, B counts from A's end. - act(() => { - renderer?.update( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 22_000, - settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), - activeTurnOpenedBy: 'B' - }) - ) - }) - ;[rowA, , rowB] = rows() - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) - expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) - } finally { - vi.useRealTimers() } - }) + ) - it("keeps a running turn's rows live across a mid-turn send the host folded in", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const tool = (id: string): NativeChatMessage => ({ - id, - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' - }) - // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. - const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] - const owned = new Map([ - ['A', 'A'], - ['t1', 'A'], - ['B', 'A'], - ['t2', 'A'] - ]) - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'A', - turnKeysByItemId: owned - }) + it.each(hosts)( + "anchors a provider-opened turn's bar above its first row, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const woke: NativeChatMessage = { + id: 'woke', + role: 'assistant', + blocks: [{ type: 'text', text: 'Woke up.' }], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('u1'), woke] + const turnJournal = journalOf( + [ + ['u1', said('user'), null], + ['wake', record('wake', 'completed', 'claude:wake'), null], + ['woke', said('assistant'), 'wake'] + ], + statesScope ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => - disclosure.resolveRow(index, message) - ) - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) - // The steered bubble shares A's turn but never carries a bar of its own. - expect(rowB.turnStatus).toBeNull() - expect(rowT1.activeTurnIsWorking).toBe(true) - expect(rowT2.activeTurnIsWorking).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it("anchors a provider-opened turn's bar above its first row", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const woke: NativeChatMessage = { - id: 'woke', - role: 'assistant', - blocks: [{ type: 'text', text: 'Woke up.' }], - timestamp: null, - source: 'transcript' + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), + turnJournal + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowU1, rowWoke] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowU1.turnStatus).toBeNull() + expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) + expect(rowWoke.turnStatusAbove).toBe(true) + } finally { + vi.useRealTimers() } - const messages = [userMessage('u1'), woke] - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - isWorking: false, - settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), - turnKeysByItemId: new Map([ - ['u1', 'u1'], - ['woke', 'wake'] - ]) - }) - ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowU1, rowWoke] = messages.map((message, index) => - disclosure.resolveRow(index, message) - ) - expect(rowU1.turnStatus).toBeNull() - expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) - expect(rowWoke.turnStatusAbove).toBe(true) - } finally { - vi.useRealTimers() } - }) + ) - it("never hands a provider-opened turn's clock to a message sent during it", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const row = (id: string): NativeChatMessage => ({ - id, - role: 'assistant', - blocks: [{ type: 'text', text: id }], - timestamp: null, - source: 'transcript' - }) - // A wake turn runs; B is sent during it and folded in, so B opened nothing. - const messages = [row('w1'), userMessage('B'), row('w2')] - const owned = new Map([ - ['w1', 'wake'], - ['B', 'wake'], - ['w2', 'wake'] - ]) - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'wake', - turnKeysByItemId: owned, - settledTurns: new Map([['wake', null]]) - }) + it.each(hosts)( + "never hands a provider-opened turn's clock to a message sent during it, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const row = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + // A wake turn runs; B is sent during it and folded in, so B opened nothing. + const messages = [row('w1'), userMessage('B'), row('w2')] + const journal = (state: 'running' | 'completed') => + journalOf( + [ + ['wake', record('wake', state, 'claude:wake'), null], + ['w1', said('assistant'), 'wake'], + ['B', said('user'), 'wake'], + ['w2', said('assistant'), 'wake'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: journal('running'), + settledTurns: new Map([['wake', null]]) + }) + ) + }) + // The wake turn settles: the host no longer names a running turn. + vi.setSystemTime(20_000) + act(() => { + renderer!.update( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + workingStartedAt: null, + turnJournal: journal('completed'), + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowW1, rowB] = messages.map((message, index) => + disclosure.resolveRow(index, message) ) - }) - // The wake turn settles: the host no longer names a running turn. - vi.setSystemTime(20_000) - act(() => { - renderer!.update( - createElement(Harness, { - messages, - enabled: true, - isWorking: false, - workingStartedAt: null, - activeTurnOpenedBy: null, - turnKeysByItemId: owned, - settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) - }) - ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowW1, rowB] = messages.map((message, index) => disclosure.resolveRow(index, message)) - expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) - expect(rowB.turnStatus).toBeNull() - } finally { - vi.useRealTimers() + expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) + expect(rowB.turnStatus).toBeNull() + } finally { + vi.useRealTimers() + } } - }) + ) + + // Claude runs A; B is sent mid-turn and Claude answers it after A. The journal writes B when it + // is sent, so A's remaining tool run and its answer follow B there, and B's turn opens after them. + it.each(hosts)( + "draws A's remaining rows under A's bar, then B's bubble and turn, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(30_000) + const message = (id: string, role: 'user' | 'assistant'): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + const messages = [ + message('A', 'user'), + message('a-tool-1', 'assistant'), + message('B', 'user'), + message('a-tool-2', 'assistant'), + message('FIRST DONE', 'assistant'), + message('b-answer', 'assistant') + ] + const turnJournal = journalOf( + [ + ['A', said('user'), null], + ['tA', record('tA', 'completed', 'A'), null], + ['a-tool-1', said('assistant'), 'tA'], + // Handed over while A runs, so the host scopes it to A's turn until its own opens. + ['B', said('user'), 'tA'], + ['a-tool-2', said('assistant'), 'tA'], + ['FIRST DONE', said('assistant'), 'tA'], + ['tB', record('tB', 'running', 'B'), null], + ['b-answer', said('assistant'), 'tB'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 29_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const list: NativeChatMessage[] = disclosure.listMessages + const drawn = list.map((entry, index) => { + const row = disclosure.resolveRow(index, entry) + return [entry.id, row.turnStatus?.workedSeconds, row.activeTurnIsWorking] + }) + expect(drawn).toEqual([ + ['A', 17, false], + ['a-tool-1', undefined, false], + ['a-tool-2', undefined, false], + ['FIRST DONE', undefined, false], + ['B', null, true], + ['b-answer', undefined, true] + ]) + } finally { + vi.useRealTimers() + } + } + ) it('keeps at most the latest 128 turns expanded', () => { vi.useFakeTimers() @@ -439,4 +718,47 @@ describe('useMobileNativeChatTurnDisclosure', () => { vi.useRealTimers() } }) + + it('keeps a turn the provider opened live while it runs, and the turn before it settled', () => { + let sequence = 0 + const entry = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } + ): AgentJournalRenderItem => { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + const said = (itemId: string, turnItemId: string) => + entry( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + { kind: 'turn', turnItemId } + ) + const items = [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + entry('t1', { kind: 'turn', turnId: 't1', state: 'completed', userItemId: 'u1' }), + said('a1', 't1'), + entry('wake', { kind: 'turn', turnId: 'wake', state: 'running', userItemId: 'claude:wake' }), + said('wake-note', 'wake') + ] + const messages: NativeChatMessage[] = ['u1', 'a1', 'wake-note'].map((id, index) => ({ + ...userMessage(id), + role: index === 0 ? 'user' : 'assistant' + })) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + settledTurns: new Map([['u1', { startedAt: 500, workedSeconds: 4 }]]), + turnJournal: { items, submissions: [] } + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const rows = messages.map((message, index) => disclosure.resolveRow(index, message)) + expect(rows.map((row) => row.activeTurnIsWorking)).toEqual([false, false, true]) + expect(rows[0].turnStatus?.workedSeconds).toBe(4) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index cc61ba63d89..6e1d4df395e 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,10 +1,14 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { NATIVE_CHAT_UNANCHORED_TURN_KEY } from '../../../src/shared/native-chat-turn-status' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { - nativeChatRowTurnKeys, - nativeChatSelfAnchoredTurnRows + nativeChatMessagesWaitingBehindLiveTurn, + nativeChatTurnMembership, + type NativeChatTurnJournal +} from '../../../src/shared/native-chat-turn-membership' +import { + nativeChatRowsInDrawOrder, + nativeChatTurnBarRows } from '../../../src/shared/native-chat-turn-grouping' import { useMobileNativeChatTurnStatus, @@ -12,8 +16,7 @@ import { } from './use-mobile-native-chat-turn-status' const EMPTY_TURN_IDS: ReadonlySet = new Set() -const EMPTY_TURN_KEYS: readonly undefined[] = [] -const EMPTY_TURN_ANCHORS: ReadonlyMap = new Map() +const NO_TURN_KEYS: readonly undefined[] = [] const MAX_EXPANDED_TURNS = 128 export type MobileNativeChatTurnRow = { @@ -35,8 +38,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, + turnJournal = null, thinking = false, activityText = null, scopeKey @@ -47,11 +49,8 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The key the host says anchors the running turn's bar; absent, the latest user message. */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership per journal item id; unnamed rows keep - * positional preceding-user grouping. */ - turnKeysByItemId?: ReadonlyMap | null + /** The journal that places each row in its turn; absent groups rows by position. */ + turnJournal?: NativeChatTurnJournal | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ @@ -64,15 +63,43 @@ export function useMobileNativeChatTurnDisclosure({ activeActivityText: string | null onToggleTurn: (turnKey: string) => void resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow + /** The list's rows, in the order they draw, less those waiting behind the live turn. */ + listMessages: readonly NativeChatMessage[] + /** Rows waiting behind the live turn, drawn after its live status. */ + waitingRows: readonly { item: NativeChatMessage; index: number }[] } { + // Resolve each row's turn, which turn is live, and the order the rows draw in, once: from the + // turn record when the host states scopes, else by journal order. + const { rows, turnKeys, liveTurnKey } = useMemo(() => { + if (!enabled) { + return { rows: messages, turnKeys: NO_TURN_KEYS, liveTurnKey: undefined } + } + const membership = nativeChatTurnMembership(messages, turnJournal) + return { + rows: nativeChatRowsInDrawOrder(messages, membership.drawOrder), + turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder), + liveTurnKey: membership.liveTurnKey + } + }, [enabled, messages, turnJournal]) + // A message waiting behind the live turn draws after that turn's live status, not in the list. + const waiting = useMemo(() => { + const ids = enabled ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items) : null + if (!ids?.size) { + return { listMessages: rows, waitingRows: [], indexById: null } + } + return { + listMessages: rows.filter((message) => !ids.has(message.id)), + waitingRows: rows.flatMap((item, index) => (ids.has(item.id) ? [{ item, index }] : [])), + indexById: new Map(rows.map((message, index) => [message.id, index])) + } + }, [enabled, rows, turnJournal]) const turnStatuses = useMobileNativeChatTurnStatus({ - messages, + turnKeys, + liveTurnKey, enabled, isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId, thinking, scopeKey }) @@ -100,59 +127,43 @@ export function useMobileNativeChatTurnDisclosure({ }, [scopeKey] ) - // Resolve each row's owning turn once — a findLast per row is quadratic on a - // long transcript. - const turnKeys = useMemo( - () => (enabled ? nativeChatRowTurnKeys(messages, turnKeysByItemId) : EMPTY_TURN_KEYS), - [enabled, messages, turnKeysByItemId] - ) - // A turn with no user bubble (provider-opened) anchors its bar at its first row. - const selfAnchors = useMemo( - () => (enabled ? nativeChatSelfAnchoredTurnRows(messages, turnKeys) : EMPTY_TURN_ANCHORS), - [enabled, messages, turnKeys] - ) + const bars = useMemo(() => nativeChatTurnBarRows(rows, turnKeys), [rows, turnKeys]) const { active, activeTurnKey, completedByTurn } = turnStatuses const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( - (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { + (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { + const index = waiting.indexById?.get(message.id) ?? listIndex const turnKey = turnKeys[index] - const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index - // The live turn's bar carries its running clock; it settles in place. Only - // the bubble that opened a turn carries its bar: a message the provider - // folded into a running turn shares the turn's key but not its bar. - const turnStatus = !enabled - ? null - : message.role === 'user' && message.id === activeTurnKey - ? active - : message.role === 'user' && turnKey === message.id - ? (completedByTurn[turnKey] ?? null) - : anchorsTurnHere - ? turnKey === activeTurnKey - ? (active ?? completedByTurn[turnKey] ?? null) - : (completedByTurn[turnKey] ?? null) - : null + const bar = turnKey === undefined ? undefined : bars.get(turnKey) + // A turn's bar draws at its first row; the live turn's carries its running clock and settles + // in place. A message folded into a turn (a steer) carries none. + const turnStatus = + enabled && turnKey !== undefined && bar?.index === index + ? turnKey === activeTurnKey + ? active + : (completedByTurn[turnKey] ?? null) + : null return { turnStatus, - ...(anchorsTurnHere && turnStatus !== null ? { turnStatusAbove: true } : {}), + ...(bar?.above === true && turnStatus !== null ? { turnStatusAbove: true } : {}), turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, // Why: the key travels and the row calls one stable handler with it. A // closure per row would be a new identity every render of a streaming // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // Liveness is the owning turn's, not the newest prompt's: a running turn's - // rows stay live while a newer message waits behind it. - activeTurnIsWorking: - enabled && - isWorking && - (turnKey === activeTurnKey || - (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) + // Liveness is the live turn's rows, not the newest prompt's: a running turn's rows stay live + // while a newer message waits behind it. With no user boundary at all, the session's + // working state stays authoritative. + activeTurnIsWorking: enabled && isWorking && turnKey === liveTurnKey } }, [ turnKeys, - selfAnchors, + waiting, + bars, + liveTurnKey, enabled, activeTurnKey, active, @@ -167,6 +178,8 @@ export function useMobileNativeChatTurnDisclosure({ activeActivityText, /** Stable for a given chat scope, so it never disturbs a row's memo. */ onToggleTurn: toggleExpandedTurn, - resolveRow + resolveRow, + listMessages: waiting.listMessages, + waitingRows: waiting.waitingRows } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 62f6ada8d5e..f7d440dd738 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,9 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { NATIVE_CHAT_UNANCHORED_TURN_KEY, reduceNativeChatTurnTiming, - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatSettledTurns, type NativeChatTurnStatus, @@ -22,27 +20,24 @@ type ScopedTurnTiming = { /** Per-turn "Thinking / Working for N / Worked for N" timing, on the same shared * state machine the desktop renderer uses so the two surfaces stamp turns alike. */ export function useMobileNativeChatTurnStatus({ - messages, + turnKeys, + liveTurnKey, enabled, isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, thinking = false, scopeKey }: { - messages: readonly NativeChatMessage[] + /** Each row's turn, as `nativeChatTurnMembership` places it. */ + turnKeys: readonly (string | undefined)[] + /** The live turn, whose bar carries the running clock (`nativeChatTurnMembership`). */ + liveTurnKey: string | undefined enabled: boolean isWorking: boolean workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The user message the host says opened the running turn; absent, the latest one. */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership; a turn keyed to its own record is still in - * the transcript, so its clock is not an echo to hand to the next prompt. */ - turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ @@ -52,9 +47,7 @@ export function useMobileNativeChatTurnStatus({ completedByTurn: Readonly> activeTurnKey: string } { - const activeTurnKey = enabled - ? selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) - : NATIVE_CHAT_UNANCHORED_TURN_KEY + const activeTurnKey = (enabled ? liveTurnKey : undefined) ?? NATIVE_CHAT_UNANCHORED_TURN_KEY const [scopedTiming, setScopedTiming] = useState(() => ({ scopeKey, timingByTurn: {} @@ -73,12 +66,7 @@ export function useMobileNativeChatTurnStatus({ if (!enabled) { return } - const validTurnKeys = new Set( - messages.filter((message) => message.role === 'user').map((message) => message.id) - ) - for (const turnKey of turnKeysByItemId?.values() ?? []) { - validTurnKeys.add(turnKey) - } + const validTurnKeys = new Set(turnKeys.filter((turnKey) => turnKey !== undefined)) const previousActiveTurnKey = previousActiveTurn.current?.scopeKey === scopeKey ? previousActiveTurn.current.turnKey @@ -99,7 +87,7 @@ export function useMobileNativeChatTurnStatus({ ? current : { scopeKey, timingByTurn: nextTiming } }) - }, [activeTurnKey, enabled, isWorking, messages, scopeKey, turnKeysByItemId, workingStartedAt]) + }, [activeTurnKey, enabled, isWorking, scopeKey, turnKeys, workingStartedAt]) // Why: the selection rebuilds its status objects on every call, and a streaming // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh diff --git a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts new file mode 100644 index 00000000000..f1b0e501fce --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts @@ -0,0 +1,132 @@ +// Ack-lost sends: a relay drop mid-send usually loses only the acknowledgement — +// the desktop already delivered the message. Hold the send instead of claiming +// failure (which baits a duplicate): stay quiet when the transcript echo lands +// or the host shows it as a queued-draft card, and surface the uncertainty only +// if the deadline passes without either. The composer was already cleared at +// send time, so this never touches drafts. + +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedUnconfirmedSends, + findQueuedUnconfirmedSends, + type UnconfirmedSend +} from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' + +const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 +const NO_QUEUED_CARDS: readonly QueuedCardText[] = [] + +type QueuedCardText = { messageId: string; text: string } + +function settledUnconfirmedSends( + messages: readonly NativeChatMessage[], + queuedCards: readonly QueuedCardText[], + entries: readonly UnconfirmedSend[] +): UnconfirmedSend[] { + const landed = findLandedUnconfirmedSends(messages, entries) + const landedSet = new Set(landed) + return [ + ...landed, + ...findQueuedUnconfirmedSends( + queuedCards, + entries.filter((entry) => !landedSet.has(entry)) + ) + ] +} + +export function useMobileNativeChatUnconfirmedSends(args: { + draftKey: string | null + pendingKey: string | null + messages: readonly NativeChatMessage[] + /** The active pane's queued-draft cards; a send the host holds as one is delivered. */ + queuedCards?: readonly QueuedCardText[] +}): { + holdUnconfirmedSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + onUnconfirmed: () => void + ) => void +} { + const { draftKey, pendingKey, messages, queuedCards = NO_QUEUED_CARDS } = args + const messagesRef = useRef(messages) + const queuedCardsRef = useRef(queuedCards) + const activeDraftKeyRef = useRef(draftKey) + const activePendingKeyRef = useRef(pendingKey) + // Read only by the post-send hold, which runs after the commit that set them. + useLayoutEffect(() => { + messagesRef.current = messages + queuedCardsRef.current = queuedCards + activeDraftKeyRef.current = draftKey + activePendingKeyRef.current = pendingKey + }, [draftKey, messages, pendingKey, queuedCards]) + const mountedRef = useRef(false) + const unconfirmedRef = useRef([]) + const holdUnconfirmedSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { + if (!mountedRef.current) { + return + } + const isActiveTranscript = + activeDraftKeyRef.current === origin.draftKey && + (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) + const entry: UnconfirmedSend = { + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + ...(origin.baselineQueuedMessageIds + ? { baselineQueuedMessageIds: origin.baselineQueuedMessageIds } + : {}), + deadline: null + } + // Why: the transcript event (or the card) can beat the lost RPC acknowledgement. + if ( + isActiveTranscript && + settledUnconfirmedSends(messagesRef.current, queuedCardsRef.current, [entry]).length > 0 + ) { + return + } + entry.deadline = setTimeout(() => { + unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) + onUnconfirmed() + }, UNCONFIRMED_SEND_DEADLINE_MS) + unconfirmedRef.current = [...unconfirmedRef.current, entry] + }, + [] + ) + + useEffect(() => { + if (!draftKey || unconfirmedRef.current.length === 0) { + return + } + const relevant = unconfirmedRef.current.filter( + (entry) => + entry.draftKey === draftKey && + (entry.pendingKey === null || entry.pendingKey === pendingKey) + ) + const landed = settledUnconfirmedSends(messages, queuedCards, relevant) + if (landed.length === 0) { + return + } + const landedSet = new Set(landed) + unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) + for (const entry of landed) { + clearTimeout(entry.deadline ?? undefined) + } + }, [messages, queuedCards, draftKey, pendingKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + for (const entry of unconfirmedRef.current) { + clearTimeout(entry.deadline ?? undefined) + } + unconfirmedRef.current = [] + } + }, []) + + return { holdUnconfirmedSend } +} diff --git a/mobile/src/session/use-mobile-structured-agent-mutation.ts b/mobile/src/session/use-mobile-structured-agent-mutation.ts new file mode 100644 index 00000000000..2d3c95f613e --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-mutation.ts @@ -0,0 +1,74 @@ +// The one mutation seam the structured session hook hands its features: builds +// the envelope, retains replayable operation ids per intent, and folds refusals +// into the session's error channel. + +import { useCallback } from 'react' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId, + type StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' + +export type MobileStructuredAgentMutate = ( + method: string, + fingerprintMethod: string, + fields: Record +) => Promise> + +export function useMobileStructuredAgentMutate(args: { + client: RpcClient | null + sessionId: string | null + sessionKey: string + enabled: boolean + stateRef: { readonly current: StructuredAgentSessionState } + operationIds: Map + onSendError: (message: string) => void +}): MobileStructuredAgentMutate { + const { client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef } = args + return useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record + ): Promise> => { + const current = stateRef.current + if (!client || !sessionId || !enabled || current.fence === null) { + return { status: 'rejected' } + } + const targetFence = current.fence + const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = retainStructuredSessionOperationId( + operationIds, + key, + operationIds.get(key) + ) + const result = await requestStructuredAgentSessionMutation({ + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence: targetFence, + fields, + clientOperationId + }) + if (result.status === 'accepted') { + operationIds.delete(key) + return { + status: 'accepted', + value: result.value, + sameFence: stateRef.current.fence === targetFence + } + } + if (result.status === 'unknown') { + operationIds.delete(key) + return result + } + operationIds.delete(key) + onSendError(result.message) + return { status: 'rejected' } + }, + [client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef] + ) +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 2e18bce750c..97591878d7e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -121,7 +121,11 @@ function Harness({ enabled: true, connected: true, agent: 'codex', - hostSupport: { promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported }, + hostSupport: { + promptCancel: promptCancelSupported, + questionAnswers: questionAnswersSupported, + queuedMessages: false + }, onSendError: vi.fn() }) return null @@ -139,7 +143,8 @@ describe('mobile structured prompt cancellation', () => { fence: 3, items: [runningTurn(), pendingApproval()], submissions: [], - retainedItemLimit: 1024, + retainedOwnItemLimit: 1024, + retainedItemCap: 8192, hasOlder: false, status: 'ready' } diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts new file mode 100644 index 00000000000..e55a26e40c0 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -0,0 +1,129 @@ +// The published shapes the queued-message hook tests feed the mobile session: host answers, +// drafts, submissions and stream frames. + +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { RpcResponse } from '../transport/types' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' + +export const SESSION_ID = 'session-1' + +export const CAPABLE: StructuredAgentSessionHostSupport = { + promptCancel: false, + questionAnswers: false, + queuedMessages: true +} +export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } + +export function ok(result: unknown): RpcResponse { + return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +/** The fields one recorded request carried, read without asserting their shape. */ +export function fieldsOf(value: unknown): Record { + return typeof value === 'object' && value !== null + ? Object.fromEntries(Object.entries(value)) + : {} +} + +export function mutationOk(value: unknown) { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value + }) +} + +/** A settled submission; `queuedMessageId` marks it as that draft's hand-off. */ +export function acceptedSubmission( + clientMessageId: string, + queuedMessageId?: string +): AgentJournalSubmission { + return { + clientMessageId, + ...(queuedMessageId ? { queuedMessageId } : {}), + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: `item-${clientMessageId}`, + reason: null, + submittedAt: 10, + resolvedAt: 11 + } +} + +export function queuedDraft( + overrides: Partial & { messageId: string } +): AgentSessionQueuedMessage { + return { + position: 1, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: `text of ${overrides.messageId}` }] + }, + state: 'waiting', + ...overrides + } +} + +export function snapshotEvent(input?: { + queuedMessages?: AgentSessionQueuedMessage[] | null + runningTurn?: boolean + submissions?: AgentJournalSubmission[] +}): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: SESSION_ID, + fence: 3, + page: { + sessionId: SESSION_ID, + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items: input?.runningTurn + ? [ + { + itemId: 'turn-item-1', + revision: 1, + body: { kind: 'turn', turnId: 'turn-1', state: 'running' }, + sequence: 1, + observedAt: 1 + } + ] + : [], + removedItemIds: [], + submissions: input?.submissions ?? [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + ...(input?.queuedMessages !== undefined ? { queuedMessages: input.queuedMessages } : {}) + } +} + +export function batchEvent( + queuedMessages?: AgentSessionQueuedMessage[] | null, + submissions: AgentJournalSubmission[] = [], + queuePause?: AgentSessionQueuePause | null +): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: SESSION_ID, + batch: { + cursor: { epoch: 'epoch-1', sequence: 2 }, + items: [], + removedItemIds: [], + submissions + }, + ...(queuedMessages !== undefined ? { queuedMessages } : {}), + ...(queuePause !== undefined ? { queuePause } : {}) + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx new file mode 100644 index 00000000000..0b27efac61d --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -0,0 +1,808 @@ +// The mid-turn queue on mobile: `delivery` rides only capability-gated sends, +// published drafts render as cards (never optimistic bubbles), and card actions +// map to the queued-message RPCs. Stop never touches the queue — its cards +// stay on the host under a queue-level pause, lifted by Resume, and no text +// travels back over the wire. +// Edit copies the card's shown text into the composer before its delete +// leaves, so no RPC outcome can lose it. An incapable host gets exactly +// today's requests. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + CAPABLE, + LEGACY, + SESSION_ID, + acceptedSubmission, + batchEvent, + fieldsOf, + mutationOk, + ok, + queuedDraft, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +describe('mobile structured queued messages', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + let stored: Map + const onSendError = vi.fn() + const appendText = vi.fn((_text: string) => true) + const sendRequest = vi.fn() + const subscribe = vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }) + const client: RpcClient = { + sendRequest, + subscribe, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + + function Harness({ + hostSupport, + sessionId = SESSION_ID + }: { + hostSupport: StructuredAgentSessionHostSupport + sessionId?: string + }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport, + appendComposerText: appendText, + onSendError + }) + return null + } + + async function mountSession( + hostSupport: StructuredAgentSessionHostSupport, + event: AgentSessionSubscribeEvent = snapshotEvent(), + sessionId?: string + ): Promise { + act(() => { + renderer = create( + createElement(Harness, { hostSupport, ...(sessionId ? { sessionId } : {}) }) + ) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(event)) + } + + function unmountSession(): void { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null + } + + function calls(method: string) { + return sendRequest.mock.calls.filter(([calledMethod]) => calledMethod === method) + } + + /** Params of the `index`th `method` request, and its envelope. */ + function requestOf(method: string, index = 0) { + const params = fieldsOf(calls(method)[index]?.[1]) + return { params, envelope: fieldsOf(params.envelope) } + } + + /** Global invocation order of the first `method` request, for cross-spy ordering. */ + function callOrderOf(method: string): number | undefined { + return sendRequest.mock.invocationCallOrder.find( + (_, index) => sendRequest.mock.calls[index]?.[0] === method + ) + } + + beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + stored = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + stored.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + stored.delete(key) + }) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + ) + }) + + afterEach(() => { + vi.useRealTimers() + unmountSession() + }) + + describe('capability-gated delivery', () => { + it('sends delivery: queue-if-active — fingerprint included — only on a capable host', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('queue me')).toBe('queued') + }) + const { params, envelope } = requestOf('agentSession.send') + expect(params.delivery).toBe('queue-if-active') + expect(envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION_ID, + fields: { body: params.body, delivery: 'queue-if-active' } + }) + ) + // Spent at `queued`: the durable send-operation entry is released. + await vi.waitFor(() => + expect(stored.has('orca:mobileStructuredSendOperations:v1')).toBe(false) + ) + }) + + it('keeps today’s request exactly against an incapable host', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 10 + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(LEGACY) + await act(async () => { + expect(await hook!.sendWithOutcome('plain send')).toBe('accepted') + }) + const { params, envelope } = requestOf('agentSession.send') + expect('delivery' in params).toBe(false) + expect(envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION_ID, + fields: { body: params.body } + }) + ) + }) + + it('replays an ack-lost delivery send under one id and the delivery it was sent with', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + }) + unmountSession() + // The capability probe has not answered after the reload, but the recorded + // operation must replay bit-for-bit — content-derived key, same id, same + // delivery field — or the host would refuse it as a fingerprint conflict. + await mountSession(LEGACY) + await act(async () => { + expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + }) + expect(attempts).toBe(2) + const first = requestOf('agentSession.send', 0) + const second = requestOf('agentSession.send', 1) + expect(second.params.delivery).toBe('queue-if-active') + expect(second.envelope.clientOperationId).toBe(first.envelope.clientOperationId) + // Nothing new is persisted: an older build still reads the send journal. + const journal = stored.get('orca:mobileStructuredSendOperations:v1') ?? '' + expect(journal).not.toContain('delivery') + }) + + it('retires an ack-lost delivery send an older host refuses, so the next send goes out plain', async () => { + const journalKey = 'orca:mobileStructuredSendOperations:v1' + let attempts = 0 + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts <= 2) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + // The downgraded host's strict schema turns `delivery` away before running anything. + if ('delivery' in fieldsOf(params)) { + return { + id: 'request-1', + ok: false, + error: { code: 'invalid_argument', message: 'Unrecognized key: "delivery"' } + } + } + return mutationOk({ + clientMessageId: 'client-plain', + submission: { + clientMessageId: 'client-plain', + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 10 + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') + }) + unmountSession() + await mountSession(LEGACY) + // A lost answer is still doubt: the replay keeps the id and its delivery. + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') + }) + const first = requestOf('agentSession.send', 0) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe( + first.envelope.clientOperationId + ) + expect(stored.get(journalKey)).toContain(String(first.envelope.clientOperationId)) + // The host answering that it cannot take the request retires the entry, once. + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('rejected') + }) + const refused = requestOf('agentSession.send', 2) + expect(refused.params.delivery).toBe('queue-if-active') + expect(refused.envelope.clientOperationId).toBe(first.envelope.clientOperationId) + expect(onSendError).toHaveBeenCalledTimes(1) + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('accepted') + }) + const plain = requestOf('agentSession.send', 3) + expect('delivery' in plain.params).toBe(false) + expect(plain.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + expect(attempts).toBe(4) + }) + + it('keeps an ack-lost send when the host refuses its replay as unauthorized', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + // An auth refusal says nothing about whether the first attempt was delivered. + return { + id: 'request-1', + ok: false, + error: { code: 'unauthorized', message: 'Pairing revoked' } + } + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('in doubt')).toBe('unknown') + }) + await act(async () => { + expect(await hook!.sendWithOutcome('in doubt')).toBe('rejected') + }) + await act(async () => { + await hook!.sendWithOutcome('in doubt') + }) + const first = requestOf('agentSession.send', 0) + expect(requestOf('agentSession.send', 2).envelope.clientOperationId).toBe( + first.envelope.clientOperationId + ) + }) + }) + + it('an ack-lost send is spent once the host publishes it as a draft, even one later withdrawn', async () => { + const journalKey = 'orca:mobileStructuredSendOperations:v1' + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return mutationOk({ + clientMessageId: `client-${attempts}`, + queued: { messageId: `client-${attempts}`, position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE, snapshotEvent({ runningTurn: true })) + await act(async () => { + expect(await hook!.sendWithOutcome('held')).toBe('unknown') + }) + const operationId = String(requestOf('agentSession.send').envelope.clientOperationId) + expect(stored.get(journalKey)).toContain(operationId) + // Someone else's draft proves nothing about this send. + act(() => listener?.(batchEvent([queuedDraft({ messageId: 'other-device' })]))) + await act(async () => {}) + expect(stored.get(journalKey)).toContain(operationId) + // The host names the draft by this send's operation id: that is its receipt. + act(() => listener?.(batchEvent([queuedDraft({ messageId: operationId })]))) + await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) + // Deleted elsewhere: no submission will ever settle it, and nothing has to. + act(() => listener?.(batchEvent(null))) + await act(async () => { + expect(await hook!.sendWithOutcome('held')).toBe('queued') + }) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(operationId) + }) + + it('an identical send whose retained id replays as withdrawn goes out fresh', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + const state = attempts === 2 ? 'withdrawn' : 'waiting' + // A pruned deleted card's receipt names no place in the queue: position 0. + return mutationOk({ + clientMessageId: `client-${attempts}`, + queued: { + messageId: `client-${attempts}`, + position: state === 'withdrawn' ? 0 : 1, + state + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + // A Delete spent the ack-lost draft before it reached the agent; typing the + // same words again is a new message, not a replay to swallow. + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(attempts).toBe(3) + const ids = [0, 1, 2].map( + (index) => requestOf('agentSession.send', index).envelope.clientOperationId + ) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + }) + + it('a withdrawn replay whose record storage will not clear still goes out fresh, and says so', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + const state = attempts === 2 ? 'withdrawn' : 'waiting' + return mutationOk({ + clientMessageId: `client-${attempts}`, + queued: { messageId: `client-${attempts}`, position: 1, state } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + // The retained record cannot be cleared; that must not keep this text from being sent. + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(attempts).toBe(3) + const ids = [0, 1, 2].map( + (index) => requestOf('agentSession.send', index).envelope.clientOperationId + ) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + expect(onSendError).toHaveBeenCalledWith( + "Sent, but this phone couldn't update its record of sent messages." + ) + }) + + describe('a lost answer, then Stop, then the drain under a fresh id', () => { + const journalKey = 'orca:mobileStructuredSendOperations:v1' + let attempts = 0 + let lostId = '' + + beforeEach(() => { + attempts = 0 + lostId = '' + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.send') { + attempts += 1 + const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) + if (attempts === 1) { + lostId = id + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + if (id === lostId) { + // The replay answers with the hand-off, which names the replayed id as its draft. + return mutationOk({ + clientMessageId: id, + submission: acceptedSubmission('fresh-hand-off', id) + }) + } + return mutationOk({ + clientMessageId: id, + queued: { messageId: id, position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + }) + + it('spends the record once the stream carries the hand-off naming it; a direct send does not', async () => { + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + expect(stored.get(journalKey)).toContain(lostId) + // A direct send's submission names no draft, so it settles nothing here. + act(() => listener?.(batchEvent(undefined, [acceptedSubmission('someone-else')]))) + await act(async () => {}) + expect(stored.get(journalKey)).toContain(lostId) + // The drain's hand-off went out under a fresh id and names the lost send's draft. + act(() => listener?.(batchEvent(undefined, [acceptedSubmission('fresh-hand-off', lostId)]))) + await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(attempts).toBe(2) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(lostId) + }) + + it('spends the record from a replay answered by the hand-off, which no page carries', async () => { + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + // The host holds that message now; the phone paints no bubble for it. The stream never + // carries the hand-off, so the answer's link is what spends the record. + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe(lostId) + await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(requestOf('agentSession.send', 2).envelope.clientOperationId).not.toBe(lostId) + }) + }) + + describe('cards from the published list', () => { + it('renders published drafts as cards and follows later frames', async () => { + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + expect(hook!.queued.cards).toEqual([ + { + messageId: 'draft-1', + text: 'text of draft-1', + state: 'waiting', + paused: false, + needsAttention: false, + caption: null + } + ]) + // A frame without the field leaves the list alone; null empties it. + act(() => listener?.(batchEvent())) + expect(hook!.queued.cards).toHaveLength(1) + act(() => listener?.(batchEvent(null))) + expect(hook!.queued.cards).toEqual([]) + }) + + it('hides a waiting card once a submission names it as its hand-off, as the desktop does', async () => { + await mountSession( + CAPABLE, + snapshotEvent({ + queuedMessages: [ + queuedDraft({ messageId: 'drained' }), + queuedDraft({ messageId: 'still-waiting' }) + ], + submissions: [acceptedSubmission('fresh-hand-off-id', 'drained')] + }) + ) + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['still-waiting']) + }) + + it('never hides a card by a direct send that merely shares its id', async () => { + await mountSession( + CAPABLE, + snapshotEvent({ + queuedMessages: [queuedDraft({ messageId: 'same-id' })], + submissions: [acceptedSubmission('same-id')] + }) + ) + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['same-id']) + }) + + it('shows no cards from an incapable host even if a list arrives', async () => { + await mountSession( + LEGACY, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + expect(hook!.queued.cards).toEqual([]) + }) + }) + + describe('card actions', () => { + it('Send-now consumes through agentSession.queuedMessageSend', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageSend') { + return mutationOk({ + clientMessageId: 'draft-1', + // Every hand-off goes out under a fresh id and names its draft. + submission: { + clientMessageId: 'hand-off-1', + queuedMessageId: 'draft-1', + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: null + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.queued.send('draft-1')).toBe(true) + }) + expect(requestOf('agentSession.queuedMessageSend').params.messageId).toBe('draft-1') + }) + + it('Delete reads the union result: a dispatched draft was already sent', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.queued.delete('draft-1')).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith('This message was already sent.') + }) + + it('Edit copies the card’s shown text into the composer before its delete leaves', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return mutationOk({ deleted: true, messageId: 'draft-1' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.edit('draft-1')).toBe(true) + }) + // The copy comes from the card the user is looking at, not from any answer. + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + // Copy-first: the text was in the composer before the delete RPC left, so + // no Delete outcome can lose it. + expect(appendText.mock.invocationCallOrder[0]).toBeLessThan( + callOrderOf('agentSession.queuedMessageDelete')! + ) + // A plain delete: only the message id goes out, and nothing durable is written. + expect(requestOf('agentSession.queuedMessageDelete').params.messageId).toBe('draft-1') + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) + + it('Edit keeps the copied text when the delete fails; the card stays beside it', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return { id: 'request-1', ok: false, error: { code: 'runtime_error', message: 'boom' } } + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.edit('draft-1')).toBe(false) + }) + // The user sees both copies — composer text and the surviving card — and + // can press Delete again; nothing is lost and nothing is restored twice. + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['draft-1']) + }) + + it('Edit racing the drain still copies; the user hears it already went out', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.edit('draft-1')).toBe(false) + }) + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + // The copy is still in the composer: say so, or it reads as unsent and goes out twice. + expect(onSendError).toHaveBeenCalledWith('Already sent — your text is still in the composer.') + }) + + it('Edit that copied nothing deletes nothing and does not report a copy', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + ) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + appendText.mockReturnValueOnce(false) + const onCopied = vi.fn() + await act(async () => { + expect(await hook!.queued.edit('draft-1', onCopied)).toBe(false) + }) + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + expect(onCopied).not.toHaveBeenCalled() + expect(callOrderOf('agentSession.queuedMessageDelete')).toBeUndefined() + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['draft-1']) + }) + }) + + describe('Stop leaves the queue alone', () => { + it('a capable Stop is a plain cancel; the cards stay and the queue reads as paused', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.cancel') { + return mutationOk({ cancelled: true, turnId: 'turn-1' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ + runningTurn: true, + queuedMessages: [queuedDraft({ messageId: 'draft-1' })] + }) + ) + await act(async () => { + expect(await hook!.cancelPrompt()).toBe(true) + }) + // Exactly today's cancel: no withdrawal ask, no text owed back, nothing durable. + const { params } = requestOf('agentSession.cancel') + expect(Object.keys(params).sort()).toEqual(['envelope', 'turnId']) + expect(params.turnId).toBe('turn-1') + expect(appendText).not.toHaveBeenCalled() + expect(asyncStorage.setItem).not.toHaveBeenCalled() + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['draft-1']) + // The host's pause is the queue's, published beside the list; the card itself is not held. + act(() => + listener?.(batchEvent([queuedDraft({ messageId: 'draft-1' })], [], { reason: 'stopped' })) + ) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + expect(hook!.queued.cards[0]).toMatchObject({ + messageId: 'draft-1', + paused: false, + caption: null + }) + // A frame without the list leaves the pause alone; one that publishes the list states it. + act(() => listener?.(batchEvent())) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + act(() => listener?.(batchEvent([queuedDraft({ messageId: 'draft-1' })], [], null))) + expect(hook!.queued.pause).toBeNull() + }) + + it('shows no pause while Resume would send nothing: only returned, blocked or failed cards', async () => { + const returned = queuedDraft({ messageId: 'r', state: 'returned', returnedReason: 'refused' }) + const behind = queuedDraft({ messageId: 'b', position: 2 }) + const failed = queuedDraft({ messageId: 'f', paused: true, pausedReason: 'send_failed' }) + await mountSession(CAPABLE, snapshotEvent({ queuedMessages: [] })) + for (const list of [[returned], [returned, behind], [failed]]) { + act(() => listener?.(batchEvent(list, [], { reason: 'stopped' }))) + expect(hook!.queued.pause).toBeNull() + } + act(() => listener?.(batchEvent([failed, behind], [], { reason: 'stopped' }))) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + }) + + it('Resume lifts the pause through its RPC, and a refusal reaches the error banner', async () => { + let refuse = false + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessagesResume') { + return refuse + ? ok({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'Could not resume.' } + }) + : mutationOk({ resumed: true }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.resume()).toBe(true) + }) + expect(Object.keys(requestOf('agentSession.queuedMessagesResume').params)).toEqual([ + 'envelope' + ]) + refuse = true + await act(async () => { + expect(await hook!.queued.resume()).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith(expect.any(String)) + }) + + it('an incapable Stop is exactly today’s cancel', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.cancel') { + return mutationOk({ cancelled: true, turnId: 'turn-1' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(LEGACY, snapshotEvent({ runningTurn: true })) + await act(async () => { + expect(await hook!.cancelPrompt()).toBe(true) + }) + const { params } = requestOf('agentSession.cancel') + expect(Object.keys(params).sort()).toEqual(['envelope', 'turnId']) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index bcc24c5ed32..04b8e87304a 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -10,6 +10,7 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-sessi import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' @@ -365,7 +366,7 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent())) - let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + let outcome: MobileNativeChatSendOutcome = 'rejected' await act(async () => { outcome = await hook!.sendWithOutcome('hello') }) @@ -496,7 +497,7 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent(3))) - let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + let outcome: MobileNativeChatSendOutcome = 'rejected' await act(async () => { outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [ { path: '/tmp/a.png', previewUri: 'file:///a.jpg' } @@ -534,7 +535,7 @@ describe('useMobileStructuredAgentSession', () => { act(() => listener?.(snapshotEvent(3))) sendRequest.mockClear() - let outcome: 'accepted' | 'unknown' | 'rejected' = 'accepted' + let outcome: MobileNativeChatSendOutcome = 'accepted' await act(async () => { outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg']) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index f04d78cc9a7..8b7e1551401 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,13 +1,8 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' -import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' -import { - structuredAgentSessionSendBody, - type StructuredAgentSessionAttachment -} from '../../../src/shared/structured-agent-session-outbox' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection' +import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, isStructuredAgentSessionThinking @@ -19,12 +14,6 @@ import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' -import { - requestStructuredAgentSessionMutation, - retainStructuredSessionOperationId as retainStructuredOpId, - timeoutForDeadline, - type StructuredAgentSessionMutationResult -} from './mobile-structured-agent-session-rpc' import type { RpcClient } from '../transport/rpc-client' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' @@ -35,17 +24,20 @@ import { useMobileStructuredPromptResponses } from './use-mobile-structured-prom import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' -import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation' import { pendingStructuredPromptIdentity, requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' - -type StructuredMobileAttachment = StructuredAgentSessionAttachment & { - id?: string - contentFingerprint?: string -} +import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import { + useMobileStructuredSendWithOutcome, + type StructuredMobileSendAttachment +} from './use-mobile-structured-send-with-outcome' +import { + useMobileStructuredQueuedMessageControls, + type MobileStructuredQueuedMessageControls +} from './use-mobile-structured-queued-message-controls' type StructuredMobileSession = ReturnType & ReturnType & { @@ -58,7 +50,7 @@ type StructuredMobileSession = ReturnType Promise cancel: () => void permission: MobileChatPermission | null @@ -66,6 +58,8 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise + /** The queued-draft cards and their actions; empty and inert off capable hosts. */ + queued: MobileStructuredQueuedMessageControls } export function useMobileStructuredAgentSession(args: { @@ -81,72 +75,46 @@ export function useMobileStructuredAgentSession(args: { /** Capability facts from the shared runtime status probe; null follows the legacy wire. */ hostSupport: StructuredAgentSessionHostSupport | null agent: string | null + /** The active pane's live composer; Edit copies a card's text through it. */ + appendComposerText?: (text: string) => boolean onSendError: (message: string) => void + /** Called on any accepted queued-card action; retires the route's failure banner. */ + onActionResolved?: () => void }): StructuredMobileSession { const { agent, + appendComposerText, callerIdentity = '', client, connected, sessionId, sourceIdentity = '', enabled, + onActionResolved, onSendError, hostSupport } = args + // Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop. + const queueCapable = hostSupport?.queuedMessages === true const promptCancelSupported = hostSupport?.promptCancel ?? null const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) const commandPendingRef = useRef(false) useEffect(() => () => operationIdsRef.current.clear(), []) const stateArgs = { client, sessionId, sessionKey, enabled, connected } - const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) - useMobileStructuredSendOperationReconciliation(state.submissions) + const { state, stateRef, queuedMessages, queuePause, loadingOlder, loadEarlier } = + useMobileStructuredAgentState(stateArgs) + useMobileStructuredSendOperationReconciliation(state.submissions, queuedMessages) - const mutate = useCallback( - async ( - method: string, - fingerprintMethod: string, - fields: Record - ): Promise> => { - const current = stateRef.current - if (!client || !sessionId || !enabled || current.fence === null) { - return { status: 'rejected' } - } - const targetFence = current.fence - const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = retainStructuredOpId( - operationIdsRef.current, - key, - operationIdsRef.current.get(key) - ) - const result = await requestStructuredAgentSessionMutation({ - client, - method, - fingerprintMethod, - sessionId, - expectedRuntimeFence: targetFence, - fields, - clientOperationId - }) - if (result.status === 'accepted') { - operationIdsRef.current.delete(key) - return { - status: 'accepted', - value: result.value, - sameFence: stateRef.current.fence === targetFence - } - } - if (result.status === 'unknown') { - operationIdsRef.current.delete(key) - return result - } - operationIdsRef.current.delete(key) - onSendError(result.message) - return { status: 'rejected' } - }, - [client, enabled, onSendError, sessionId, sessionKey] - ) + const mutate = useMobileStructuredAgentMutate({ + client, + sessionId, + sessionKey, + enabled, + stateRef, + operationIds: operationIdsRef.current, + onSendError + }) const options = useMobileStructuredAgentOptions({ agent, @@ -158,86 +126,31 @@ export function useMobileStructuredAgentSession(args: { }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options - - const sendWithOutcome = useCallback( - async ( - text: string, - images?: string[], - deadline?: number, - attachments?: readonly StructuredMobileAttachment[] - ): Promise => { - const currentFence = stateRef.current.fence - if (!client || !sessionId || !enabled || currentFence === null) { - onSendError('Message not sent (disconnected)') - return 'rejected' - } - const timeoutMs = timeoutForDeadline(deadline) - if (timeoutMs === null) { - onSendError('Message not sent') - return 'rejected' - } - if (attachments === undefined && images !== undefined && images.length > 0) { - onSendError('Message not sent') - return 'rejected' - } - const sendAttachments = attachments ?? [] - const commandOutcome = await dispatchMobileStructuredCommand({ - text, - hasAttachments: Boolean(sendAttachments.length || images?.length), - client, - sessionId, - fence: currentFence, - sessionKey, - pending: commandPendingRef, - operationIds: operationIdsRef.current, - controller: { - agent: agent === 'claude' ? 'claude' : 'codex', - snapshot: optionSnapshot, - setOption: setStructuredOption, - invokeAction: invokeStructuredOption, - conversationCommands - }, - canRun: () => - !activeStructuredAgentSessionTurnId(stateRef.current.items) && - !stateRef.current.items.some( - (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) - ), - onError: onSendError, - timeoutMs - }) - if (commandOutcome !== null) { - return commandOutcome - } - const body = structuredAgentSessionSendBody(text, sendAttachments) - if (body.blocks.length === 0) { - return 'rejected' - } - return sendMobileStructuredAgentSessionMessage({ - client, - sessionId, - sessionKey, - callerIdentity, - expectedRuntimeFence: currentFence, - text, - attachments: sendAttachments, - deadline, - onError: onSendError - }) - }, - [ - agent, - callerIdentity, - client, - conversationCommands, - enabled, - invokeStructuredOption, - onSendError, - optionSnapshot, - sessionId, - sessionKey, - setStructuredOption - ] + // Stable across renders, or the send callback is rebuilt on every streamed frame. + const sendController = useMemo( + () => ({ + snapshot: optionSnapshot, + setOption: setStructuredOption, + invokeAction: invokeStructuredOption, + conversationCommands + }), + [conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption] ) + + const sendWithOutcome = useMobileStructuredSendWithOutcome({ + agent, + callerIdentity, + client, + sessionId, + sessionKey, + enabled, + queueCapable, + stateRef, + commandPending: commandPendingRef, + operationIds: operationIdsRef.current, + controller: sendController, + onSendError + }) const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, sessionKey, @@ -246,22 +159,6 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const requestCancel = useCallback( - (prompt?: { itemId: string; expectedRevision: number }): Promise => - requestMobileStructuredAgentSessionCancel({ - client, - enabled, - onSendError, - operationIds: operationIdsRef.current, - prompt, - promptCancelSupported, - sessionId, - sessionKey, - stateRef - }), - [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] - ) - const messages = useMemo( () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), [state.items, state.submissions] @@ -281,6 +178,36 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) + const queued = useMobileStructuredQueuedMessageControls({ + queueCapable, + sessionKey, + queuedMessages, + queuePause, + submissions: state.submissions, + pendingPrompt: approvalPrompt !== null || questionPrompt !== null, + mutate, + appendComposerText, + onSendError, + ...(onActionResolved ? { onActionResolved } : {}) + }) + // Stop never touches the queue: held cards stay on the host, visible on every + // device, and resume only from the user's own next action. + const requestCancel = useCallback( + (prompt?: { itemId: string; expectedRevision: number }): Promise => + requestMobileStructuredAgentSessionCancel({ + client, + enabled, + onSendError, + operationIds: operationIdsRef.current, + prompt, + promptCancelSupported, + sessionId, + sessionKey, + stateRef + }), + [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] + ) + return { ...options, session: { @@ -292,9 +219,7 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - isWorking: - turnId !== null || - hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), + isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), turnId, turnIndicator, ...turnTiming, @@ -307,6 +232,7 @@ export function useMobileStructuredAgentSession(args: { permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, - respondQuestion + respondQuestion, + queued } } diff --git a/mobile/src/session/use-mobile-structured-agent-state.test.tsx b/mobile/src/session/use-mobile-structured-agent-state.test.tsx new file mode 100644 index 00000000000..fd319f12f48 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-state.test.tsx @@ -0,0 +1,162 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalCursor, + AgentJournalRenderItem +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' + +function cursorAt(sequence: number): AgentJournalCursor { + return { epoch: 'epoch-1', sequence } +} + +function said(sequence: number, agentId?: string): AgentJournalRenderItem { + return { + itemId: `item-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `${sequence}` }] }, + ...(agentId === undefined ? {} : { agentId }) + } +} + +function range(from: number, to: number, agentId?: string): AgentJournalRenderItem[] { + return Array.from({ length: to - from }, (_, index) => said(from + index, agentId)) +} + +function page(items: AgentJournalRenderItem[], hasOlder: boolean): AgentSessionHistoryPage { + const oldest = items[0] + const newest = items.at(-1) + return { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 1, + direction: 'before', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: oldest ? cursorAt(oldest.sequence) : null, + newest: newest ? cursorAt(newest.sequence) : null, + nextCursor: cursorAt(oldest?.sequence ?? 0) + }, + liveCursor: cursorAt(1_000), + hasOlder, + hasNewer: false + } +} + +describe('useMobileStructuredAgentState older history', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + /** Mounts the hook on a session whose newest rows are `newest` (by default one of the + * session's own, at 1000), with the host serving `older` by the cursor each read asks before. */ + async function mountWithOlderPages( + older: Record, + newest: AgentJournalRenderItem[] = [said(1_000)] + ) { + let onFrame: ((value: unknown) => void) | null = null + const historyCursors: number[] = [] + const sendRequest = vi.fn(async (method: string, params?: { cursor?: AgentJournalCursor }) => { + if (method !== 'agentSession.history' || !params?.cursor) { + return { ok: true, result: {} } + } + historyCursors.push(params.cursor.sequence) + const olderPage = older[params.cursor.sequence] + return { + ok: true, + result: olderPage ? { ok: true, page: olderPage } : { ok: false, reset: 'cursor_ahead' } + } + }) + const subscribe = vi.fn( + (_method: string, _params: unknown, frame: (value: unknown) => void) => { + onFrame = frame + return () => {} + } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an RPC client stub with the two members the hook calls. + const client = { sendRequest, subscribe } as unknown as RpcClient + const hook: { current: ReturnType | null } = { + current: null + } + function Harness(): null { + hook.current = useMobileStructuredAgentState({ + client, + sessionId: 'session-1', + sessionKey: 'session-1', + enabled: true, + connected: true + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(onFrame).not.toBeNull()) + act(() => { + onFrame!({ + type: 'snapshot', + sessionId: 'session-1', + fence: 1, + page: page(newest, true) + }) + }) + await vi.waitFor(() => expect(hook.current!.state.status).toBe('ready')) + return { hook, historyCursors } + } + + it("reads past pages of a subagent's rows to the session's own older rows", async () => { + const { hook, historyCursors } = await mountWithOlderPages({ + 1000: page(range(800, 1_000, 'task-1'), true), + 800: page(range(600, 800, 'task-1'), true), + 600: page(range(1, 101), false) + }) + + act(() => hook.current!.loadEarlier()) + + await vi.waitFor(() => expect(hook.current!.state.items[0]?.sequence).toBe(1)) + expect(historyCursors).toEqual([1_000, 800, 600]) + expect(hook.current!.state.hasOlder).toBe(false) + }) + + it("stops at the first page that holds a row of the session's own", async () => { + const { hook, historyCursors } = await mountWithOlderPages({ + 1000: page([...range(800, 999, 'task-1'), said(999)], true), + 800: page(range(600, 800), true) + }) + + act(() => hook.current!.loadEarlier()) + + await vi.waitFor(() => expect(hook.current!.state.items[0]?.sequence).toBe(800)) + expect(historyCursors).toEqual([1_000]) + expect(hook.current!.state.hasOlder).toBe(true) + }) + + it("reads back on its own when the newest page holds only a subagent's rows", async () => { + const { hook, historyCursors } = await mountWithOlderPages( + { 800: page(range(1, 101), false) }, + range(800, 1_000, 'task-1') + ) + + await vi.waitFor(() => expect(hook.current!.state.items[0]?.sequence).toBe(1)) + expect(historyCursors).toEqual([800]) + }) + + it('reads back once from a window that draws nothing when the read lands nothing', async () => { + const { hook, historyCursors } = await mountWithOlderPages({}, range(800, 1_000, 'task-1')) + + await vi.waitFor(() => expect(historyCursors).toEqual([800])) + await vi.waitFor(() => expect(hook.current!.loadingOlder).toBe(false)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(historyCursors).toEqual([800]) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index d2b6d1a5062..9eca412c541 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -1,5 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types' +import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer' import type { + AgentSessionHistoryPage, AgentSessionHistoryResult, AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' @@ -13,11 +16,25 @@ import { type StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' -import { callAgentSession } from './mobile-structured-agent-session-rpc' +import { + agentSessionReadFailureText, + callAgentSession +} from './mobile-structured-agent-session-rpc' +import { + reduceMobileQueuePause, + reduceMobileQueuedMessageFeed, + type MobileQueuedMessageFeed, + type MobileQueuePause +} from './mobile-structured-queued-message-feed' + +type QueuedFeed = { messages: MobileQueuedMessageFeed; pause: MobileQueuePause } +const NO_QUEUED_FEED: QueuedFeed = { messages: null, pause: null } const MAX_RETAINED_SESSION_STATES = 32 /** Bounded so a busy stream cannot turn one Load-earlier tap into an endless read chain. */ const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 +/** Bounds the pages one Load-earlier reads past that hold only subagent rows. */ +const OLDER_PAGES_PER_LOAD = 8 /** * Opens the transcript stream once the hold settles, either way: a refused hold is an older host @@ -65,6 +82,10 @@ export function useMobileStructuredAgentState(args: { }): { state: StructuredAgentSessionState stateRef: { readonly current: StructuredAgentSessionState } + /** Host-held queued drafts from the live stream; null until the host claims any. */ + queuedMessages: MobileQueuedMessageFeed + /** The whole queue's pause, published with the drafts. */ + queuePause: MobileQueuePause loadingOlder: boolean loadEarlier: () => void } { @@ -74,10 +95,14 @@ export function useMobileStructuredAgentState(args: { const [sessionStates, setSessionStates] = useState>( () => new Map() ) + const [queuedBySession, setQueuedBySession] = useState>(() => new Map()) const state = enabled && sessionKey ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) : EMPTY_STRUCTURED_AGENT_SESSION + const queued = + (enabled && sessionKey ? queuedBySession.get(sessionKey) : undefined) ?? NO_QUEUED_FEED + const queuedMessages = queued.messages const [loadingOlder, setLoadingOlder] = useState(false) const stateRef = useRef(state) const sessionKeyRef = useRef(sessionKey) @@ -114,6 +139,34 @@ export function useMobileStructuredAgentState(args: { [sessionKey] ) + const applyQueued = useCallback( + (event: AgentSessionSubscribeEvent) => { + if (!sessionKey) { + return + } + setQueuedBySession((current) => { + const previous = current.get(sessionKey) ?? NO_QUEUED_FEED + const messages = reduceMobileQueuedMessageFeed(previous.messages, event) + const pause = reduceMobileQueuePause(previous.pause, event) + if (messages === previous.messages && pause === previous.pause) { + return current + } + const updated = new Map(current) + updated.delete(sessionKey) + updated.set(sessionKey, { messages, pause }) + while (updated.size > MAX_RETAINED_SESSION_STATES) { + const oldest = updated.keys().next().value + if (oldest === undefined) { + break + } + updated.delete(oldest) + } + return updated + }) + }, + [sessionKey] + ) + useEffect(() => { streamGenerationRef.current += 1 sessionKeyRef.current = sessionKey @@ -134,11 +187,12 @@ export function useMobileStructuredAgentState(args: { }) const endStream = openTranscriptAfterHold(client, sessionId, held, (raw) => { if (typeof raw === 'object' && raw !== null && 'type' in raw && raw.type === 'error') { - apply({ type: 'error', message: 'message' in raw ? String(raw.message ?? '') : '' }) + apply({ type: 'error', message: agentSessionReadFailureText(raw) }) return } if (isSubscribeEvent(raw)) { apply({ type: 'event', event: raw }) + applyQueued(raw) } }) return () => { @@ -158,7 +212,7 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, client, connected, enabled, sessionId, sessionKey]) + }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey]) const loadEarlier = useCallback(() => { const current = stateRef.current @@ -182,24 +236,51 @@ export function useMobileStructuredAgentState(args: { if (!cursor || !isCurrentRead()) { return } - const result = await callAgentSession( - client, - 'agentSession.history', - { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } - ) - if (!result.ok || !isCurrentRead()) { + // Mobile draws only the session's own rows, so a page of a subagent's rows alone + // would land as nothing; read on until a page adds a row the reader can see. Older + // hosts send one for any burst; current ones when the page's byte bound cuts it short. + const pages: { requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage }[] = [] + let requestedCursor = cursor + while (pages.length < OLDER_PAGES_PER_LOAD) { + const result = await callAgentSession( + client, + 'agentSession.history', + { + sessionId, + direction: 'before', + cursor: requestedCursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + } + ) + if (!result.ok || !isCurrentRead()) { + break + } + pages.push({ requestedCursor, page: result.page }) + if ( + !result.page.hasOlder || + result.page.items.length === 0 || + result.page.items.some(isRootAgentJournalItem) + ) { + break + } + requestedCursor = result.page.window.nextCursor + } + if (pages.length === 0 || !isCurrentRead()) { return } - // The reducer drops a page whose anchor slid, so only an intact anchor lands. + // The reducer drops a page whose anchor slid, so only an intact anchor lands; + // each later page abuts the one before it. if (oldestStructuredAgentSessionCursor(stateRef.current)?.sequence === cursor.sequence) { - apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) + for (const { requestedCursor: pageCursor, page } of pages) { + apply({ type: 'older-page', requestedCursor: pageCursor, page }) + } return } } })() .catch((error: unknown) => { if (isCurrentRead()) { - apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + apply({ type: 'error', message: agentSessionReadFailureText(error) }) } }) .finally(() => { @@ -209,5 +290,21 @@ export function useMobileStructuredAgentState(args: { }) }, [apply, client, loadingOlder, sessionId, sessionKey]) - return { state, stateRef, loadingOlder, loadEarlier } + // A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled + // to ask for more, so it reads back once from each such head. A first page can be one: an + // older host's for any burst, a current host's when a burst fills its byte bound. + const drawsNothingFrom = + state.status === 'ready' && state.hasOlder && !state.items.some(isRootAgentJournalItem) + ? `${sessionKey}:${state.epoch}:${state.items[0]?.sequence}` + : null + const readBackFromRef = useRef(null) + useEffect(() => { + if (drawsNothingFrom === null || loadingOlder || readBackFromRef.current === drawsNothingFrom) { + return + } + readBackFromRef.current = drawsNothingFrom + loadEarlier() + }, [drawsNothingFrom, loadEarlier, loadingOlder]) + + return { state, stateRef, queuedMessages, queuePause: queued.pause, loadingOlder, loadEarlier } } diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx index 944b1d9be13..bc14028c872 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx @@ -86,7 +86,8 @@ describe('useMobileStructuredAgentTurnTiming', () => { vi.useFakeTimers() vi.setSystemTime(CLIENT_NOW) const items = [ - user('u1', 1), + // The acknowledged send: its entry's key is the submission's, not the provider's. + user('orca:first', 1), lifecycle( 't1', 2, diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 9181856cc0d..34f0df7edff 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { StructuredAgentHostClock } from '../../../src/shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' import { @@ -12,10 +13,8 @@ import { } from '../../../src/shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the transcript key that owns the running turn's bar, each - * row's owning turn, and a - * skew-free start for the live counter whose host-to-local conversion is latched - * once per turn. */ + * off the journal, and a skew-free start for the live counter whose host-to-local + * conversion is latched once per turn. */ export function useMobileStructuredAgentTurnTiming( { items, @@ -29,14 +28,15 @@ export function useMobileStructuredAgentTurnTiming( turnId: string | null ): { settledTurns: NativeChatSettledTurns + /** What places each transcript row in its turn; the same read desktop makes. */ + turnJournal: NativeChatTurnJournal workingStartedAt: number | null - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( + const { settledTurns, runningTiming } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) + const turnJournal = useMemo(() => ({ items, submissions }), [items, submissions]) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of // a new turn already counts from the right instant. @@ -50,10 +50,5 @@ export function useMobileStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { - settledTurns, - workingStartedAt: step.workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId - } + return { settledTurns, turnJournal, workingStartedAt: step.workingStartedAt } } diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index 3cf432381c2..aadd8de4a1a 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -76,6 +76,19 @@ describe('useMobileStructuredNativeChatSendBridge', () => { expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + it('never echoes a queued send as an optimistic transcript bubble', async () => { + // The host holds the draft and publishes it as a card above the composer; + // a `pending-N` bubble here would show the message twice and retire never. + sendStructured.mockResolvedValue('queued') + mount('claude') + + await expect(sendWithOutcome('queue me')).resolves.toBe('queued') + + expect(acceptSend).not.toHaveBeenCalled() + expect(holdUnconfirmedSend).not.toHaveBeenCalled() + expect(restoreRejectedDraft).not.toHaveBeenCalled() + }) + it('keeps host-command reconciliation for Codex', async () => { sendStructured.mockResolvedValue('unknown') mount('codex') diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 83482cd6e2e..5abe42b39e6 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -76,6 +76,11 @@ export function useMobileStructuredNativeChatSendBridge(args: { } return 'accepted' } + if (outcome === 'queued') { + // The host holds the draft and publishes it as a card above the + // composer — never an optimistic transcript bubble. + return 'queued' + } if (outcome === 'unknown') { if (isHostCommand) { restoreRejectedDraft(origin, text) diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts new file mode 100644 index 00000000000..4e033c6dd97 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -0,0 +1,165 @@ +// The queued-draft surface the structured session exposes: cards derived from +// the published list and the Send-now / Delete / Edit actions. All of it is +// gated on the host capability — an incapable host gets no cards and no new +// fields. Nothing here is durable: the host owns the queue, and the published +// list is the only truth a card action ever needs. + +import { useCallback, useMemo } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult +} from '../../../src/shared/agent-session-wire' +import { + mobileQueueHasResumableCard, + mobileQueuedMessageCards, + type MobileQueuedMessageCard +} from './mobile-structured-queued-message-cards' +import type { + MobileQueuedMessageFeed, + MobileQueuePause +} from './mobile-structured-queued-message-feed' +import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' + +/** `onCopied` runs once the card's text is in the composer, before its Delete leaves. */ +export type MobileQueuedMessageEdit = (messageId: string, onCopied?: () => void) => Promise + +export type MobileStructuredQueuedMessageControls = { + /** Host-held drafts as cards above the composer; empty off capable hosts. */ + cards: MobileQueuedMessageCard[] + /** Send-now: dispatch this draft into or ahead of the running turn. */ + send: (messageId: string) => Promise + /** Discard the draft. */ + delete: (messageId: string) => Promise + /** Copy the card's shown text into the composer, then delete the card. */ + edit: MobileQueuedMessageEdit + /** The whole queue's pause, shown above the cards; null when it sends on its own. */ + pause: MobileQueuePause + /** Lift the queue's pause, so the waiting cards drain. */ + resume: () => Promise + /** The conversation these belong to; the card list's in-flight guards never outlive it. */ + sessionKey: string +} + +export function useMobileStructuredQueuedMessageControls(args: { + queueCapable: boolean + sessionKey: string + queuedMessages: MobileQueuedMessageFeed + queuePause: MobileQueuePause + submissions: readonly AgentJournalSubmission[] + pendingPrompt: boolean + mutate: MobileStructuredAgentMutate + /** The active pane's live composer, Edit's copy target; absent = Edit refuses. False when + * nothing was copied. */ + appendComposerText: ((text: string) => boolean) | undefined + onSendError: (message: string) => void + /** Called on any accepted card action, so the route can retire a held failure banner. */ + onActionResolved?: () => void +}): MobileStructuredQueuedMessageControls { + const { + appendComposerText, + mutate, + onActionResolved, + onSendError, + pendingPrompt, + queueCapable, + queuedMessages, + queuePause, + sessionKey, + submissions + } = args + const cards = useMemo( + () => + queueCapable + ? mobileQueuedMessageCards(queuedMessages, submissions, { + pendingPrompt, + queuePaused: queuePause !== null + }) + : [], + [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + ) + const resolved = useCallback( + (accepted: boolean): boolean => { + if (accepted) { + onActionResolved?.() + } + return accepted + }, + [onActionResolved] + ) + const send = useCallback( + async (messageId: string): Promise => + resolved( + ( + await mutate( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId } + ) + ).status === 'accepted' + ), + [mutate, resolved] + ) + const deleteQueued = useCallback( + async (messageId: string, copied: boolean): Promise => { + const result = await mutate( + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessageDelete', + { messageId } + ) + if (result.status !== 'accepted') { + return false + } + if (!result.value.deleted && result.value.disposition === 'dispatched') { + // Delete raced the drain; the message went out and is in the transcript. After Edit's + // copy, say so, or the composer's text reads as unsent and goes out twice. + onSendError( + copied + ? 'Already sent — your text is still in the composer.' + : 'This message was already sent.' + ) + return false + } + return resolved(true) + }, + [mutate, onSendError, resolved] + ) + const deleteDraft = useCallback( + (messageId: string) => deleteQueued(messageId, false), + [deleteQueued] + ) + const edit = useCallback( + async (messageId, onCopied) => { + const card = cards.find((candidate) => candidate.messageId === messageId) + // Copy-first: the text is in the composer before any RPC can fail, so no + // Delete outcome — including a lost answer — can lose it. A failed Delete + // leaves the card beside the copy, visibly, never a silent duplicate. No + // copy (no composer yet, or an empty card) means no Delete: Edit never + // removes text it did not keep. + if (!card || !appendComposerText?.(card.text)) { + return false + } + onCopied?.() + return deleteQueued(messageId, true) + }, + [appendComposerText, cards, deleteQueued] + ) + // Through the same mutation seam as Delete: a refusal reaches the send-error banner. + const resume = useCallback( + async (): Promise => + resolved( + ( + await mutate( + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} + ) + ).status === 'accepted' + ), + [mutate, resolved] + ) + // The header shows only while Resume would send something, as on desktop. + const pause = queueCapable && mobileQueueHasResumableCard(cards) ? queuePause : null + return { cards, send, delete: deleteDraft, edit, pause, resume, sessionKey } +} diff --git a/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts b/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts index 1b18613bc7c..65d0f318466 100644 --- a/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts +++ b/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts @@ -1,11 +1,24 @@ import { useEffect } from 'react' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import { clearMobileStructuredSettledSendOperations } from './mobile-structured-send-operation-journal' +import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' +import { + clearMobileStructuredQueuedSendOperations, + clearMobileStructuredSettledSendOperations +} from './mobile-structured-send-operation-journal' export function useMobileStructuredSendOperationReconciliation( - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + queuedMessages: MobileQueuedMessageFeed = null ): void { useEffect(() => { void clearMobileStructuredSettledSendOperations({ submissions }).catch(() => undefined) }, [submissions]) + useEffect(() => { + if (!queuedMessages || queuedMessages.length === 0) { + return + } + void clearMobileStructuredQueuedSendOperations({ + queuedMessageIds: queuedMessages.map((draft) => draft.messageId) + }).catch(() => undefined) + }, [queuedMessages]) } diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts new file mode 100644 index 00000000000..937cc58b0eb --- /dev/null +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -0,0 +1,143 @@ +// The structured composer's one send seam: a slash command dispatches as a +// conversation command, and everything else goes out as an +// `agentSession.send` — carrying `delivery: 'queue-if-active'` only when the +// host advertises the queue. + +import { useCallback } from 'react' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { + structuredAgentSessionSendBody, + type StructuredAgentSessionAttachment +} from '../../../src/shared/structured-agent-session-outbox' +import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' +import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' +import { + pendingStructuredApproval, + pendingStructuredQuestion +} from './mobile-structured-agent-prompts' + +export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { + id?: string + contentFingerprint?: string +} + +export function useMobileStructuredSendWithOutcome(args: { + agent: string | null + callerIdentity: string + client: RpcClient | null + sessionId: string | null + sessionKey: string + enabled: boolean + queueCapable: boolean + stateRef: { readonly current: StructuredAgentSessionState } + commandPending: { current: boolean } + operationIds: Map + controller: Pick< + StructuredAgentSessionComposerOptions, + 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' + > + onSendError: (message: string) => void +}): ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileSendAttachment[] +) => Promise { + const { + agent, + callerIdentity, + client, + commandPending, + controller, + enabled, + onSendError, + operationIds, + queueCapable, + sessionId, + sessionKey, + stateRef + } = args + return useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileSendAttachment[] + ): Promise => { + const currentFence = stateRef.current.fence + if (!client || !sessionId || !enabled || currentFence === null) { + onSendError('Message not sent (disconnected)') + return 'rejected' + } + const timeoutMs = timeoutForDeadline(deadline) + if (timeoutMs === null) { + onSendError('Message not sent') + return 'rejected' + } + if (attachments === undefined && images !== undefined && images.length > 0) { + onSendError('Message not sent') + return 'rejected' + } + const sendAttachments = attachments ?? [] + const commandOutcome = await dispatchMobileStructuredCommand({ + text, + hasAttachments: Boolean(sendAttachments.length || images?.length), + client, + sessionId, + fence: currentFence, + sessionKey, + pending: commandPending, + operationIds, + controller: { + agent: agent === 'claude' ? 'claude' : 'codex', + ...controller + }, + canRun: () => + !activeStructuredAgentSessionTurnId(stateRef.current.items) && + !stateRef.current.items.some( + (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) + ), + onError: onSendError, + timeoutMs + }) + if (commandOutcome !== null) { + return commandOutcome + } + const body = structuredAgentSessionSendBody(text, sendAttachments) + if (body.blocks.length === 0) { + return 'rejected' + } + return sendMobileStructuredAgentSessionMessage({ + client, + sessionId, + sessionKey, + callerIdentity, + expectedRuntimeFence: currentFence, + text, + attachments: sendAttachments, + ...(queueCapable ? { delivery: 'queue-if-active' as const } : {}), + deadline, + onError: onSendError + }) + }, + [ + agent, + callerIdentity, + client, + commandPending, + controller, + enabled, + onSendError, + operationIds, + queueCapable, + sessionId, + sessionKey, + stateRef + ] + ) +} diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts index d52f4861fd4..4935d514f36 100644 --- a/mobile/src/source-control/mobile-pr-create.test.ts +++ b/mobile/src/source-control/mobile-pr-create.test.ts @@ -158,32 +158,6 @@ describe('mobile create form gating parity', () => { ) }) - it('desktop gate hard-blocks on positive unresolved review evidence', () => { - // Mobile lacks review-lookup signals, so it fails closed on ambiguity: the - // shared desktop gate must return false even when eligibility looks ready. - expect( - shouldOpenChecksPanelCreateComposer({ - activeReview: null, - isFolder: false, - branch: 'feature/x', - hostedReviewCreation: eligibility({ canCreate: true }), - reviewLookup: 'positive_unresolved' - }) - ).toBe(false) - }) - - it('desktop gate hard-blocks during a hard refresh error', () => { - expect( - shouldOpenChecksPanelCreateComposer({ - activeReview: null, - isFolder: false, - branch: 'feature/x', - hostedReviewCreation: eligibility({ canCreate: true }), - hasHardRefreshError: true - }) - ).toBe(false) - }) - it('fails closed on an unavailable review lookup even when eligibility looks ready', () => { // The existing-review lookup could not prove there is no PR; mobile has no // review-lookup signal of its own, so create must be blocked. diff --git a/mobile/src/source-control/source-control-router-seam-census.test.ts b/mobile/src/source-control/source-control-router-seam-census.test.ts deleted file mode 100644 index e34d4ad93d8..00000000000 --- a/mobile/src/source-control/source-control-router-seam-census.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - importsExpoRouterValue, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const SOURCE_CONTROL_ROOT = import.meta.dirname - -/** - * Which modules here hold a router, so the census cannot pass by seeing nothing. - * - * One holder, not one per screen: the hub's router is taken once in the openers hook and passed - * down through the state hook to the runners and the panel. So this domain's whole reach into the - * router is that single call, and the rules below say so rather than counting screens. - * - * `use-mobile-source-control-runners.ts` is the case a value/type rule is written for: it named - * expo-router only to write `ReturnType`, which is a value import in a type - * position and keeps the module in the graph. `RouteHandoff` is the seam's own name for that type. - */ -const ROUTER_HOLDERS = ['use-mobile-source-control-openers.ts'] - -describe('the source-control domain reaches the router through the handoff seam', () => { - const files = productFiles(SOURCE_CONTROL_ROOT) - - it('walks the modules it is written against', () => { - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - expect(files).toContain('use-mobile-source-control-runners.ts') - expect(files).toContain('MobileSourceControlPanel.tsx') - }) - - it('imports no router from expo-router, which the page cannot hand a route back through', () => { - expect( - files.filter((name) => importsExpoRouterValue(parse(SOURCE_CONTROL_ROOT, name))) - ).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen that holds one', () => { - expect( - files.filter((name) => callsRouteHandoff(parse(SOURCE_CONTROL_ROOT, name))).sort() - ).toEqual([...ROUTER_HOLDERS].sort()) - }) -}) diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts index f6e3eab8873..b30bd883f8e 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts @@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { buildMobileHostedReviewEligibilityLoadKey, - eligibilityStateAfterMobileHostedReviewError, renderedMobileHostedReviewEligibilityState, shouldFetchMobileHostedReviewEligibility, useMobileHostedReviewEligibility, @@ -101,38 +100,6 @@ describe('mobile hosted review eligibility loader core', () => { expect(local.identity).not.toBe(ssh.identity) expect(local.fetch).not.toBe(ssh.fetch) }) - - it('renders a superseded same-identity snapshot as loading', () => { - const input = { - hostId: 'host-1', - worktreeId: 'wt-1', - branch: 'feature', - hasUpstream: true, - ahead: 0, - behind: 0 - } - const older = buildMobileHostedReviewEligibilityLoadKey({ - ...input, - hasUncommittedChanges: false - }) - const newest = buildMobileHostedReviewEligibilityLoadKey({ - ...input, - hasUncommittedChanges: true - }) - - expect(older.identity).toBe(newest.identity) - expect( - renderedMobileHostedReviewEligibilityState({ - snapshot: loadSnapshot(older, { kind: 'ready', eligibility: eligibility() }), - key: newest, - shouldFetch: true - }) - ).toMatchObject({ kind: 'loading', eligibility: eligibility() }) - }) - - it('fails closed after errors', () => { - expect(eligibilityStateAfterMobileHostedReviewError()).toEqual({ kind: 'error' }) - }) }) // #8411: what the hook returns is what paints. A fetch-imminent frame must not diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts index 5b0c3f04241..fdf2c5ea5c0 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts @@ -54,7 +54,7 @@ export function shouldFetchMobileHostedReviewEligibility( return input.connState === 'connected' && input.client !== null && !!input.branch } -export function eligibilityStateAfterMobileHostedReviewError(): MobileCreatePrEligibilityState { +function eligibilityStateAfterMobileHostedReviewError(): MobileCreatePrEligibilityState { return { kind: 'error' } } diff --git a/mobile/src/tasks/agent-launch-mobile-replay.test.ts b/mobile/src/tasks/agent-launch-mobile-replay.test.ts index 12a3bf495a0..aa654675f36 100644 --- a/mobile/src/tasks/agent-launch-mobile-replay.test.ts +++ b/mobile/src/tasks/agent-launch-mobile-replay.test.ts @@ -12,7 +12,8 @@ import { } from '../../../src/shared/agent-session-host-authority' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' @@ -30,7 +31,7 @@ beforeEach(async () => { createStructuredSession.mockReset() createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-mobile-launch-replay-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only deps.store; structured session creation is the injected boundary above. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/mobile/src/tasks/github-project-repo-list-load.test.ts b/mobile/src/tasks/github-project-repo-list-load.test.ts deleted file mode 100644 index c330c0d306c..00000000000 --- a/mobile/src/tasks/github-project-repo-list-load.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -/** Wiring assertions only. Keep these anchored to the modules that own each path. */ -const readSource = (path: string): string => readFileSync(new URL(path, import.meta.url), 'utf8') -const taskListSource = readSource('./use-mobile-tasks-task-list-loading.tsx') -const projectProjectionSource = readSource('./use-mobile-tasks-project-projection.tsx') -const projectLoadingSource = readSource('./use-mobile-tasks-project-loading-actions.tsx') -const projectEffectsSource = readSource('./use-mobile-tasks-list-and-detail-effects.tsx') -const projectListSource = readSource('./mobile-tasks-github-project-list.tsx') -const providerItemListSource = readSource('./mobile-tasks-provider-item-list.tsx') -const screenChromeSource = readSource('./mobile-tasks-screen-chrome.tsx') -const routeAndItemStateSource = readSource('./use-mobile-tasks-route-and-item-state.tsx') -const repositoryResolutionSource = readSource( - './use-mobile-tasks-project-repository-resolution.tsx' -) -const clientSettingsSource = readSource('./use-mobile-tasks-client-settings-actions.tsx') -const source = [ - taskListSource, - projectProjectionSource, - projectLoadingSource, - projectEffectsSource, - projectListSource, - providerItemListSource, - screenChromeSource, - routeAndItemStateSource, - repositoryResolutionSource, - clientSettingsSource -].join('\n') - -/** Why: `slice(start, -1)` silently trims one byte instead of failing, so a - * missing end marker would let every assertion below pass vacuously. */ -function block(input: string, startMarker: string, endMarker: string): string { - const start = input.indexOf(startMarker) - expect(start, `source must contain ${startMarker}`).toBeGreaterThan(-1) - const end = input.indexOf(endMarker, start) - expect(end, `${startMarker} must be followed by ${endMarker}`).toBeGreaterThan(start) - return input.slice(start, end) -} - -function loadTasksBody(): string { - return block( - taskListSource, - 'const loadTasks = useCallback(', - ' return Object.assign(model, { loadTasks })' - ) -} - -describe('mobile GitHub Project repo list loading', () => { - // Regression (#12966): project mode returned before the repo list was ever - // loaded, so hostedRepos stayed empty and every board row was filtered out. - it('loads the repo list before project mode bails out', () => { - const body = loadTasksBody() - const repoLoad = body.indexOf('const repoListRequest = repoListEnsureLoaded()') - const repoAwait = body.indexOf('await repoListRequest') - const projectReturn = body.indexOf("provider === 'github' && githubMode === 'project'") - expect(repoLoad, 'loadTasks must load the repo list').toBeGreaterThan(-1) - expect(projectReturn, 'project mode must still short-circuit loadTasks').toBeGreaterThan(-1) - expect(repoAwait, 'the repo list must be awaited, not just started').toBeGreaterThan(repoLoad) - expect(projectReturn, 'project mode must bail out after the repo list load').toBeGreaterThan( - repoAwait - ) - }) - - it('still bails out before any work-item fetch in project mode', () => { - const body = loadTasksBody() - const projectReturn = body.indexOf("provider === 'github' && githubMode === 'project'") - const workItemFetch = body.indexOf("provider === 'github' || provider === 'gitlab'") - expect(workItemFetch, 'the hosted work-item fetch must still exist').toBeGreaterThan(-1) - expect(workItemFetch, 'project mode must not reach the issue/PR fetch').toBeGreaterThan( - projectReturn - ) - }) - - it('keeps the repo list in the resource rather than local state', () => { - expect(source).toContain('const repos = repoList.state.repos') - expect(source, 'a second copy of the list would drift from the resource').not.toContain( - 'const [repos, setRepos]' - ) - expect(source).not.toContain('reposRef') - }) -}) - -describe('mobile GitHub Project readiness and refresh', () => { - // `every` is vacuously true on an empty list, so readiness must ask the - // resource whether that list is real yet rather than infer it. - it('derives slug readiness from the resource status', () => { - const body = block( - projectProjectionSource, - 'const githubProjectRepoSlugReady = useMemo(', - ' )' - ) - expect(body).toContain('hasSettledHostRepoList(repoList.state)') - expect(body).toContain('[githubRepoSlugCache, hostedRepos, repoList.state]') - }) - - // Regression: readiness is only useful if the renderer consults it. Without - // this the board renders "No project items" before the repo list arrives, and - // the memo assertion above stays green. - it('gates the empty state on readiness in the renderer', () => { - expect(projectListSource, 'the empty state must sit behind the readiness spinner').toContain( - 'githubProjectTable && !githubProjectRepoSlugReady ? (' - ) - }) - - it('re-reads the host and retries failed slug lookups on refresh', () => { - expect(block(projectLoadingSource, 'const refreshTasks = useCallback(', '}, [')).toContain( - 'repoListReload()' - ) - - const projectBody = block( - projectEffectsSource, - 'const refreshGitHubProject = useCallback(', - '}, [' - ) - expect(projectBody).toContain('dropFailedGitHubRepoSlugEntries') - expect(projectBody).toContain('refreshTasks()') - expect(projectBody).toContain('loadGitHubProjectTable(') - }) - - it('routes every refresh control through those callbacks', () => { - expect(source).toContain('onRefresh={refreshTasks}') - expect(source).toContain('onRefresh={refreshGitHubProject}') - expect(source, 'no refresh control may call loadTasks directly').not.toContain( - 'onRefresh={() => void loadTasks(' - ) - }) - - it('marks a failed slug lookup as retryable rather than resolved', () => { - expect(source).toContain('repository: null, failed: true') - }) - - // Regression: Expo reuses this screen for the next host, so an effect-based - // reset runs a render too late and the previous host's rows show through. - it('clears the other client-scoped caches during render', () => { - const body = block(clientSettingsSource, 'if (boundClient !== client) {', '\n }') - expect(body).toContain('setItems([])') - expect(body).toContain('setGithubRepoSlugCache({})') - expect(body, 'a ref write here would leak from an abandoned render').not.toContain('.current =') - }) - - // ...and the ref half belongs in the commit phase, for the same reason. - it('resets the selection-hydration ref in the commit phase', () => { - expect(block(clientSettingsSource, 'clientRef.current = client', '}, [client])')).toContain( - 'repoSelectionHydratedRef.current = false' - ) - }) -}) diff --git a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts index 2d1828d2795..3fc2ce2a4ca 100644 --- a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts +++ b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import { OrcaRuntimeRpcServer } from '../../../src/main/runtime/runtime-rpc' import { DeviceRegistry } from '../../../src/main/runtime/device-registry' import type { AuthenticatedMobileSocket } from '../../../src/main/runtime/rpc/mobile-socket-wiring' @@ -37,7 +38,7 @@ beforeEach(async () => { createStructuredSession.mockReset() createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-launch-architecture-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads deps.store; session creation is injected above. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) @@ -241,7 +242,7 @@ describe('mobile launch retry authority', () => { } const first = await dispatcher.dispatch(request) expect(first.ok).toBe(true) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) await expect( @@ -291,7 +292,7 @@ describe('mobile launch retry authority', () => { message: 'Branch "otter" already exists locally.' } }) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) await expect(dispatcher.dispatch(request)).resolves.toMatchObject({ diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts deleted file mode 100644 index bacc24be5b8..00000000000 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - coordinateMobileTasksNavigation, - mobileTasksHostRoute, - navigateToMobileTasks, - type MobileTasksNavigationState -} from './mobile-task-navigation' - -function navigationHarness(initialState: MobileTasksNavigationState) { - let stateListener = () => {} - let state = initialState - const unsubscribeState = vi.fn() - const navigation = { - addListener: vi.fn((_event: 'state', listener: () => void) => { - stateListener = listener - return unsubscribeState - }), - dispatch: vi.fn(), - getState: () => state - } - return { - navigation, - setState(nextState: MobileTasksNavigationState) { - state = nextState - stateListener() - }, - unsubscribeState - } -} - -describe('mobile task navigation', () => { - it('waits for the expected host commit before navigating to Tasks', () => { - const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const push = vi.fn() - const replace = vi.fn() - - navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') - - expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( - push.mock.invocationCallOrder[0]! - ) - expect(push).toHaveBeenCalledWith(mobileTasksHostRoute('host/1')) - expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] - }) - expect(harness.navigation.dispatch).not.toHaveBeenCalled() - expect(harness.unsubscribeState).toHaveBeenCalledOnce() - expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - replace.mock.invocationCallOrder[0]! - ) - expect(replace).toHaveBeenCalledWith({ - pathname: '/h/[hostId]/tasks', - params: { hostId: 'host/1' } - }) - }) - - it('ignores unrelated state events and preserves the provider', () => { - const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - - navigateToMobileTasks( - harness.navigation, - { push: vi.fn(), replace: vi.fn() }, - 'host-1', - 'linear' - ) - harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) - expect(harness.navigation.dispatch).not.toHaveBeenCalled() - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) - expect(harness.navigation.dispatch).not.toHaveBeenCalled() - harness.setState({ - index: 0, - routes: [ - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }] - } - } - ] - }) - - expect(harness.navigation.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { - name: '[hostId]/tasks', - params: { hostId: 'host-1', taskSource: 'linear' } - } - }) - ) - }) - - it('cleanup prevents a stale navigation from replacing', () => { - const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const replace = vi.fn() - const controller = navigateToMobileTasks( - harness.navigation, - { push: vi.fn(), replace }, - 'host-1' - ) - - controller.cancel() - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) - - expect(harness.unsubscribeState).toHaveBeenCalledOnce() - expect(harness.navigation.dispatch).not.toHaveBeenCalled() - expect(replace).not.toHaveBeenCalled() - }) - - it('reuses a pending host push and applies the latest provider', () => { - const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const push = vi.fn() - const router = { push, replace: vi.fn() } - - const first = coordinateMobileTasksNavigation( - null, - harness.navigation, - router, - 'host-1', - 'github' - ) - const second = coordinateMobileTasksNavigation( - first, - harness.navigation, - router, - 'host-1', - 'linear' - ) - harness.setState({ - index: 0, - routes: [ - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }] - } - } - ] - }) - - expect(second).toBe(first) - expect(push).toHaveBeenCalledOnce() - expect(harness.navigation.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { - name: '[hostId]/tasks', - params: { hostId: 'host-1', taskSource: 'linear' } - } - }) - ) - }) - - it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { - const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const replace = vi.fn() - - navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h' }] }) - expect(harness.unsubscribeState).not.toHaveBeenCalled() - harness.setState({ index: 0, routes: [{ name: 'index' }] }) - harness.setState({ - index: 0, - routes: [ - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }] - } - } - ] - }) - - expect(harness.unsubscribeState).toHaveBeenCalledOnce() - expect(harness.navigation.dispatch).not.toHaveBeenCalled() - expect(replace).not.toHaveBeenCalled() - }) - - it('unsubscribes when mounting the host throws synchronously', () => { - const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const error = new Error('navigation failed') - - expect(() => - navigateToMobileTasks( - harness.navigation, - { - push: () => { - throw error - }, - replace: vi.fn() - }, - 'host-1' - ) - ).toThrow(error) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() - }) -}) diff --git a/mobile/src/tasks/mobile-task-navigation.ts b/mobile/src/tasks/mobile-task-navigation.ts index f1dc733878e..bacc314cd4d 100644 --- a/mobile/src/tasks/mobile-task-navigation.ts +++ b/mobile/src/tasks/mobile-task-navigation.ts @@ -1,28 +1,6 @@ -import { - coordinateHostStackNavigation, - hostStackHostRoute, - navigateToHostStackRoute, - type HostStackHostRoute, - type HostStackNavigationController, - type HostStackNavigationState, - type HostStackRootNavigation, - type HostStackRouteTarget, - type HostStackRouter, - type PendingHostStackNavigation -} from '../navigation/host-stack-navigation' +import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import type { TaskProvider } from './mobile-task-providers' -export type MobileTasksHostRoute = HostStackHostRoute -export type MobileTasksNavigationState = HostStackNavigationState -export type MobileTasksRootNavigation = HostStackRootNavigation -export type MobileTasksRouter = HostStackRouter -export type MobileTasksNavigationController = HostStackNavigationController -export type PendingMobileTasksNavigation = PendingHostStackNavigation - -export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute { - return hostStackHostRoute(hostId) -} - export function mobileTasksRouteTarget( hostId: string, provider?: TaskProvider @@ -32,33 +10,3 @@ export function mobileTasksRouteTarget( params: provider ? { hostId, taskSource: provider } : { hostId } } } - -export function navigateToMobileTasks( - navigation: MobileTasksRootNavigation, - router: MobileTasksRouter, - hostId: string, - provider?: TaskProvider -): MobileTasksNavigationController { - return navigateToHostStackRoute( - navigation, - router, - hostId, - mobileTasksRouteTarget(hostId, provider) - ) -} - -export function coordinateMobileTasksNavigation( - current: PendingMobileTasksNavigation | null, - navigation: MobileTasksRootNavigation, - router: MobileTasksRouter, - hostId: string, - provider?: TaskProvider -): PendingMobileTasksNavigation { - return coordinateHostStackNavigation( - current, - navigation, - router, - hostId, - mobileTasksRouteTarget(hostId, provider) - ) -} diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts deleted file mode 100644 index 4eb61e3f1da..00000000000 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it } from 'vitest' -import { - readFlattenedMobileTasksHookSignatures, - readMobileTasksSemanticSource, - readMobileTasksStyleSource -} from './mobile-tasks-source-family.test-support' -import { readFlattenedMobileTasksRenderTokens } from './mobile-tasks-render-parity.test-support' -import { - readFlattenedMobileTasksCoreStatements, - readMobileTasksDeclarationSignatures -} from './mobile-tasks-execution-parity.test-support' - -const hash = (parts: string[] | string): string => - createHash('sha256') - .update(Array.isArray(parts) ? parts.join('\n') : parts) - .digest('hex') - -// Bound requests change source signatures the same way bound provider, workspace-creation and -// settings requests did: the method string and the envelope read leave the screen and an operation -// name arrives. The behaviour they used to pin is pinned by the recordings in -// mobile/rpc-foundation/goldens instead, which did not move. -// -// The screen-holdout migration takes the last two sends out of this family — the filter sheet's -// linear.selectWorkspace and the screen-root hook's repo.list. Hook, statement, declaration, render -// and style counts are all unchanged, and `semantics` is a pure deletion of four lines, none in: -// two `rpc:` call signatures and the two method literals they carried. The render-token hash moves -// because the picker's handler now names an operation instead of the client. -// -// Step 7's first half moves four of the six again, and moves nothing else. Checked readers on the -// item and list operations delete the reply casts these consumers carried, plus the three shape -// tests the reader now answers for: both `Array.isArray(payload)` guards on the checks read and the -// `typeof count === 'number'` fallback on the item count. Hook, statement, declaration and render -// counts are unchanged, and the render-token hash does not move at all — nothing this family sees -// changed inside a JSX tree. `semantics` is a pure deletion of ten lines. -// -// Round-1 review moves four, and names what each one is. The reaction reader stops matching -// `content` against an arm set mobile invented and forwards it, so `DetailComment` loses the eight -// phantom arms and `COMMENT_REACTION_EMOJI` stops being keyed by them: that is ten string literals -// gone and the `?? ''` fallback's one added, the whole of `semantics`' 3,290 -> 3,281. The eight -// alias-only bindings the deleted casts left behind (`const result = created` and its seven -// siblings) are inlined, which moves the hook and statement hashes without moving their counts. -// Only those eight: the Linear arm of task creation keeps its own `result`, which is a declaration -// with a name rather than an alias for one. -// No `rpc:` signature and no `jsx:` signature moves, the render-token hash does not move, and -// counts stay at 350 hooks, 417 statements and 194 declarations. -// -// The `gitlab.todos` fixture correction moves the same three hashes once more and no others: the -// to-do row is checked now, so the reader's cast is gone from the list-loading hook and the row -// type it forwarded is declared by what the reader proves. Counts are unchanged again, and -// `semantics` does not move, because no RPC call, runtime string or JSX host signature does. -// -// Round 2 moves two, and only because one member widens. `GitHubDetailFile.viewerViewedState` is -// `string` rather than the host's three arms, because the reader forwards it now: that is the -// declaration hash and the three arm literals, `semantics` 3,281 -> 3,278. `status` keeps its arms -// and moves nothing, because its only consumer sends it back as a param the host validates against -// the same set. Hook, statement and render hashes do not move; nothing executable changed. -// -// Step 7's tasks-2 half moves the hook, statement, declaration and semantic hashes once more, for -// the board, runtime, search, workspace-source and workspace-create operations, and moves no count: -// hooks stay at 350, statements at 417, declarations at 194. `semantics` is a pure deletion of four -// lines, 3,278 -> 3,274, and all four are the literals inside the one inline cast type this half -// deletes in use-mobile-tasks-project-detail-loading.tsx — `'DISMISSED'`, `'VIEWED'`, `'UNVIEWED'` -// and the `['status']` index into GitHubDetailFile. No method literal and no `rpc:` call signature -// moves. -// -// The hook and statement hashes also move for comment text alone: `normalized` reads a statement's -// full span, so a comment nested inside one is hashed with it. They move once more when the two -// halves are deduplicated: the project pane's five collection casts and the assignee list's are -// deleted where the entity schemas from the item half now type those rows. -// -// Round 2 of tasks-2 moves the hook and statement hashes a last time, and only those two. One -// statement changes: the Linear list cast (`found as LinearIssue[]`) becomes `found`, because the -// nine members linearIssueRowSchema requires make the value assignable to the mobile alias without -// it. The rest is comment text nested inside statements — three `SAFETY:` lines rewritten to argue -// from the schemas that landed instead of the fixtures round 1 deleted. Counts are unchanged at 350 -// hooks and 417 statements; the declaration, semantic, render and style hashes do not move, which -// is the evidence that deleting the cast changed no type and no call. -// -// The pullfrog pass on the same round moved both once more, again by comment text alone: the -// GitHub search `SAFETY:` line now separates the two members the schema requires (`items`, -// `labels`) from the eight it only types. No statement, type or call changed; counts hold. -// C2.1 swaps the workspace-creation push onto `hostNewWorktreeSessionRoute`, which already built -// this href with both segments encoded. Three of the family move and nothing else does: the hook -// list, because the handler's statements changed shape; the statement hash, for the same reason; -// and `semantics`, which is a pure deletion of two lines — the `URLSearchParams` construction and -// the raw `/h/${hostId}/session/...` template it fed. No RPC call, method literal or JSX host -// signature changed, and the render and style hashes did not move. - -// C2.1 swaps the two clipboard writes onto the platform seam, so the comment-review hook gains one -// hook call and one statement. Two of the family move: the hook list and the statement hash, each -// by one entry. `semantics` does not — no RPC call, method literal or JSX host signature changed — -// and the render and style hashes hold. - -// C2.8 names the status bar's Back control for the shell, which has no native chrome behind it to -// announce one. `accessibilityRole="button"` and `accessibilityLabel="Back"` on that one Pressable -// move the two hashes a JSX prop must move, and only those two. `semantics` 3,272 -> 3,274: the -// element's host signature widens (`jsx:Pressable:style,onPress` -> the same plus the two props) -// and the two new runtime strings `"button"` and `"Back"` arrive. The render-token stream gains -// the eight tokens those two attributes are, 35,195 -> 35,203. No RPC call signature and no method -// literal moves, and the hook, statement, declaration and style hashes do not move at all, which -// is the evidence that nothing executable changed. -// -// The same commit gives that control the `hitSlop={8}` its four siblings carry, so its touch -// target is no longer the glyph alone. One more line of `semantics` changes and no line is added -// or removed — the host signature gains `hitSlop` — so the count holds at 3,274 and only the hash -// moves. The render-token stream gains the four tokens that one attribute is, 35,203 -> 35,207. -// Nothing else in the family moves. - -const SCREEN_RPC_SCREEN_HOOKS = '0f66df2141117dfec2f8a0adb3f598312e6fda8e80833a365a645796f5ab48c3' -const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const SCREEN_RPC_STATEMENTS = 'dd8f33cb3cf96f5c39abac397cb77e35f59079291033a1866ead462b041ab979' -// Saved Linear selections now accept unknown persisted values; reconciliation tests cover them. -const MAIN_REBASED_DECLARATIONS = 'ec77d34712c7c4ab19ac6a4d57878f32d22aba790d2420cc14c2c0f000d120e9' -const SCREEN_RPC_SEMANTICS = 'e07a63387d57106483ee703ec6c19dea593e0eca5c651758f42bcb36254850b7' -// StatusDot's spacing moved to the tasks title row as `gap: spacing.sm`; same 8 px. -const PRE_REFACTOR_STYLES = '11504e9f655d4d45deb25d942158a0ee99a1f98007ad1630bb03e1bdfe1e73de' -const SCREEN_RPC_RENDER_TREE = '086742f95f1e87fb89d8c67ffd9f7a229799ae05115f9f4bcc1a925e56dcc8bb' - -describe('Mobile Tasks refactor parity', () => { - it('preserves recursively flattened hook and dependency order', () => { - const screenHooks = readFlattenedMobileTasksHookSignatures('MobileTasksScreen') - expect(screenHooks).toHaveLength(351) - expect(hash(screenHooks)).toBe(SCREEN_RPC_SCREEN_HOOKS) - - const diffHooks = readFlattenedMobileTasksHookSignatures('GitHubPrFileDiff') - expect(diffHooks).toHaveLength(3) - expect(hash(diffHooks)).toBe(PRE_REFACTOR_DIFF_HOOKS) - }) - - it('preserves every screen statement in execution order', () => { - const statements = readFlattenedMobileTasksCoreStatements() - expect(statements).toHaveLength(418) - expect(hash(statements)).toBe(SCREEN_RPC_STATEMENTS) - }) - - it('preserves every moved top-level declaration', () => { - const declarations = readMobileTasksDeclarationSignatures() - expect(declarations).toHaveLength(194) - expect(hash(declarations)).toBe(MAIN_REBASED_DECLARATIONS) - }) - - it('preserves RPC calls, runtime strings, and JSX host signatures', () => { - const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_274) - expect(hash(semantics)).toBe(SCREEN_RPC_SEMANTICS) - }) - - it('preserves render expressions and event handlers in tree order', () => { - const tokens = readFlattenedMobileTasksRenderTokens() - expect(tokens).toHaveLength(35_207) - expect(hash(tokens)).toBe(SCREEN_RPC_RENDER_TREE) - }) - - it('preserves every StyleSheet property and value', () => { - expect(hash(readMobileTasksStyleSource())).toBe(PRE_REFACTOR_STYLES) - }) -}) diff --git a/mobile/src/terminal/document/document-parse-time-effects.test.ts b/mobile/src/terminal/document/document-parse-time-effects.test.ts index 0acddfb78b3..86b8433ed14 100644 --- a/mobile/src/terminal/document/document-parse-time-effects.test.ts +++ b/mobile/src/terminal/document/document-parse-time-effects.test.ts @@ -1,13 +1,9 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { - DECLARATION_KINDS, documentModuleNames, documentModuleSource, exportedLifecycleFunctions, - parseModule, parseTimeEffects, - readsTheDocument, sequenceCalls, topLevelDeclarationsReachAnElement } from '../../test-support/webview-document-census' @@ -71,43 +67,6 @@ describe('the document modules at parse time', () => { } }) - it('would report a planted element read, which the statement filter cannot see', () => { - // The second reader has its own precondition. A `const` initialised from the document is a - // declaration by shape and a parse-time element read by effect — the exact form that survived - // a remount holding the first mount's node — and the statement-kind filter waves it through. - const planted = - "import { scope } from './document-scope'\n" + - "const indicator = document.getElementById('scroll-indicator')\n" + - 'export function n() {\n return indicator ?? scope.term\n}\n' - expect(parseTimeEffects('planted', planted)).toEqual([ - "planted: indicator = document.getElementById('scroll-indicator')" - ]) - // And the element reader the case above spends on every module: the same plant, seen by it. - expect(topLevelDeclarationsReachAnElement('planted', planted)).toBe(true) - // And the other direction, because a reader that flagged every initialiser would agree with - // the empty list above only by refusing everything: a plain literal is not work. - const inert = - "import { scope } from './document-scope'\n" + - 'const options = { capture: true, passive: false }\n' + - 'export function n() {\n return options.capture && scope.term !== null\n}\n' - expect(parseTimeEffects('inert', inert)).toEqual([]) - }) - - it('would report one, so the empty list above is a measurement', () => { - // The precondition. A walk that matched nothing would agree with an empty expectation just as - // happily, so the same reader is aimed at a module that does have a top-level effect: this - // test file itself, whose `describe` call is exactly the shape the rule refuses. - const source = readFileSync( - new URL('./document-parse-time-effects.test.ts', import.meta.url), - 'utf8' - ) - const running = parseModule('probe', source).body.filter( - (statement) => !DECLARATION_KINDS.has(statement.type) - ) - expect(running.length).toBeGreaterThan(0) - expect(readsTheDocument({ type: 'Identifier', name: 'document' })).toBe(true) - }) - it('start and stop: the sequence calls every one there is, and undoes them in reverse', () => { // Moving an effect out is only correct if something calls it, and a count cannot say that: a // module could export a start nobody runs and the count would agree as soon as the literal @@ -120,9 +79,6 @@ describe('the document modules at parse time', () => { MODULES.filter((name) => name !== THE_SEQUENCE).flatMap((name) => exportedLifecycleFunctions(moduleSource(name), keyword, 'TerminalDocumentScope') ) - expect(moduleSource('selection-overlay')).toContain( - 'export function stopSelectionOverlay(scope: TerminalDocumentScope) {\n stopEdgeScroll(scope)' - ) const started = sequenceCallsTo('startTerminalDocument') // `cancelDocumentFrames` is the frame registry's undo rather than a module's stop, and it is @@ -145,12 +101,4 @@ describe('the document modules at parse time', () => { ) expect(sequenceCallsTo('stopTerminalDocument').at(-1)).toBe('cancelDocumentFrames') }) - - it('would name a start the sequence forgot, which is what the comparison above is for', () => { - // The precondition, planted rather than argued: a module that exports a start nobody calls is - // the failure the set comparison exists to catch, and the reader has to say its name. - const planted = sequenceCallsTo('startTerminalDocument') - expect(planted).not.toContain('startReflow') - expect([...planted, 'startReflow'].sort()).not.toEqual(planted.slice().sort()) - }) }) diff --git a/mobile/src/terminal/document/host-seams.test.ts b/mobile/src/terminal/document/host-seams.test.ts index 17d709ddc42..c000062acf0 100644 --- a/mobile/src/terminal/document/host-seams.test.ts +++ b/mobile/src/terminal/document/host-seams.test.ts @@ -7,7 +7,6 @@ import { notify } from './host-notify' import { flog } from './viewport-transform' import { attachWebglAddon } from './webgl-recovery' import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams' -import { documentSourceText } from './document-module-source.test-support' /** * The host seams the page sets, and the window reads and writes they default to. @@ -324,38 +323,4 @@ describe("the document's viewport", () => { window.dispatchEvent(new Event('resize')) expect(scope.panX).toBe(0) }) - - it('is read through the seam everywhere, never off the window directly', () => { - // The default in `document-host-seams.ts` is the one window read, so the census runs over - // every other module; a raw read elsewhere sizes a page terminal to the whole page. - const raw = documentSourceText() - .split('\n') - .filter((line) => /window\.inner(Height|Width)|\binner(Height|Width)\b/.test(line)) - expect(raw).toEqual([ - ' return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }' - ]) - }) - - it('maps a client point into the grid only through viewportPoint', () => { - // On the page the host's origin is not the window's, so a client coordinate used against - // anything but another client coordinate lands rows low. Differences of two need no origin. - const arithmetic = documentSourceText() - .split('\n') - .filter((line) => !/^\s*(\*|\/\/)/.test(line)) - .filter((line) => /client[XY]\s*[-+*/<>]|[-+*/<>]=?\s*[\w.[\]]*client[XY]\b/.test(line)) - .map((line) => line.trim()) - .sort() - expect(arithmetic).toEqual([ - 'const dx = Math.abs(e.clientX - gesture.startX)', - 'const dx = Math.abs(mt.clientX - scope.tapCandidate.x)', - 'const dx = Math.abs(t.clientX - scope.longPressOrigin.x)', - 'const dx = a.clientX - b.clientX,', - 'const dy = Math.abs(e.clientY - gesture.startY)', - 'const dy = Math.abs(mt.clientY - scope.tapCandidate.y)', - 'const dy = Math.abs(t.clientY - scope.longPressOrigin.y)', - 'dy = a.clientY - b.clientY', - 'return viewportPoint(scope, (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2)', - 'return { x: clientX - frame.left, y: clientY - frame.top }' - ]) - }) }) diff --git a/mobile/src/terminal/terminal-cell-box-census.test.ts b/mobile/src/terminal/terminal-cell-box-census.test.ts deleted file mode 100644 index ea0e9ece12c..00000000000 --- a/mobile/src/terminal/terminal-cell-box-census.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url)) - -// xterm is the only measurer of the cell box. These are the ways to measure a glyph without it, -// matched by technique rather than by name, so a rename cannot hide one. xterm's own code is the -// generated engine, which the walk leaves out. -const GLYPH_MEASUREMENT: [string, RegExp][] = [ - ['canvas text metrics', /\bmeasureText\s*\(/], - ['offscreen canvas', /\bOffscreenCanvas\b/], - ['font bounding box', /\b(font|actual)BoundingBox(Ascent|Descent|Left|Right)\b/] -] -// A probe span: a created span, a run of one glyph, and its laid-out width, all in one file. -const PROBE_SPAN = [ - /createElement\(\s*['"`]span['"`]\s*\)/, - /['"`][A-Za-z0-9]['"`]\s*\.repeat\s*\(/, - /\b(offsetWidth|getBoundingClientRect)\b/ -] - -function productSources(): { path: string; text: string }[] { - return censusSourceFiles(SOURCE_ROOT) - .map((path) => ({ path, name: relative(SOURCE_ROOT, path).replaceAll('\\', '/') })) - .filter(({ name }) => /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) - .map(({ path, name }) => ({ path: name, text: readFileSync(path, 'utf8') })) -} - -function glyphMeasurements(text: string): string[] { - const found = GLYPH_MEASUREMENT.filter(([, pattern]) => pattern.test(text)).map(([kind]) => kind) - return PROBE_SPAN.every((pattern) => pattern.test(text)) ? [...found, 'probe span'] : found -} - -describe('the cell box has one measurer', () => { - it('walks the product sources, including the document that reports the box', () => { - const paths = productSources().map((source) => source.path) - expect(paths).toContain('terminal/document/laid-out-cell-box.ts') - expect(paths).toContain('terminal/document/terminal-init.ts') - }) - - it('recognises each technique it fences', () => { - expect(glyphMeasurements("ctx.measureText('W')")).toEqual(['canvas text metrics']) - expect(glyphMeasurements('new OffscreenCanvas(1, 1)')).toEqual(['offscreen canvas']) - expect(glyphMeasurements('m.fontBoundingBoxAscent + m.fontBoundingBoxDescent')).toEqual([ - 'font bounding box' - ]) - expect( - glyphMeasurements( - "const s = document.createElement('span'); s.textContent = 'W'.repeat(32); s.offsetWidth" - ) - ).toEqual(['probe span']) - }) - - it('has no file under mobile/src measure a glyph outside xterm', () => { - const found = productSources().flatMap(({ path, text }) => - glyphMeasurements(text).map((kind) => `${path}: ${kind}`) - ) - expect(found).toEqual([]) - }) -}) diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index abf1bd01045..50fcda6ec52 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -2,23 +2,12 @@ import type { RefObject } from 'react' import { describe, expect, it, vi } from 'vitest' import type { ConnectionState } from '../transport/types' import type { TerminalWebViewHandle } from './TerminalWebView' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { TERMINAL_FOREGROUND_RECOVERY_DELAY_MS, recoverActiveTerminalAfterForeground, shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' -const lifecycleSource = readMobileSessionRouteSource('../session/use-mobile-session-lifecycle.ts') - -function sliceSessionSource(startPattern: string, endPattern: string): string { - const start = lifecycleSource.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = lifecycleSource.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return lifecycleSource.slice(start, end) -} - type RecoveryHarness = { activeHandleRef: RefObject terminalRefs: RefObject> @@ -139,37 +128,4 @@ describe('terminal foreground recovery', () => { expect(harness.subscribeToTerminal).not.toHaveBeenCalled() }) - - it('is wired to AppState foregrounding in the session screen', () => { - const foregroundPredicate = sliceSessionSource( - 'const shouldRecover = shouldRecoverTerminalOnAppStateChange(', - 'previousAppState = nextAppState' - ) - - expect(lifecycleSource).toContain('shouldRecoverTerminalOnAppStateChange') - expect(foregroundPredicate).toContain('Platform.OS') - expect(lifecycleSource).toContain('recoverActiveTerminalAfterForeground({') - expect(lifecycleSource).toContain("AppState.addEventListener('change'") - const readinessInvalidation = lifecycleSource.indexOf( - 'terminalRef.prepareForForegroundRecovery()' - ) - const replay = lifecycleSource.indexOf('recoverActiveTerminalAfterForeground({') - expect(readinessInvalidation).toBeGreaterThanOrEqual(0) - expect(replay).toBeGreaterThan(readinessInvalidation) - }) - - it('re-runs a deferred recovery once the session screen reconnects', () => { - // Why: resume usually lands mid-reconnect; the session screen must retry - // recovery on the connState→connected transition or blanked panes stay - // stale until a manual tab switch. - expect(lifecycleSource).toContain( - "pendingForegroundRecoveryRef.current = outcome === 'deferred'" - ) - const reconnectRetry = sliceSessionSource( - "if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)", - 'recoverActiveTerminalAfterForeground({' - ) - expect(reconnectRetry).toContain('pendingForegroundRecoveryRef.current = false') - expect(reconnectRetry).toContain("AppState.currentState !== 'active'") - }) }) diff --git a/mobile/src/terminal/terminal-input-connection-gate.test.ts b/mobile/src/terminal/terminal-input-connection-gate.test.ts index 4d55c31bcf4..747f0eaab4c 100644 --- a/mobile/src/terminal/terminal-input-connection-gate.test.ts +++ b/mobile/src/terminal/terminal-input-connection-gate.test.ts @@ -1,28 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate' import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const runtimeSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-runtime.ts' -) -const sendActionsSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-send-actions.ts' -) -const terminalInputSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-input.ts' -) -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') - -function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { - const start = source.indexOf(anchorStart) - expect(start).toBeGreaterThanOrEqual(0) - // Why: a duplicated start anchor would silently slice the wrong region. - expect(source.indexOf(anchorStart, start + 1)).toBe(-1) - const end = source.indexOf(anchorEnd, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end + anchorEnd.length) -} describe('terminal input connection gate', () => { it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => { @@ -83,74 +61,9 @@ describe('terminal input connection gate', () => { }) }) -describe('session route offline-compose wiring', () => { - it('derives both gates from the shared resolver', () => { - expect(runtimeSource).toContain('resolveMobileTerminalInputGate({') - }) - - it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { - const bufferedInput = sourceSlice( - commandDockSource, - 'ref={bindCommandField}', - 'onSubmitEditing={() => void handleSend()}' - ) - expect(bufferedInput).toContain('editable={canCompose}') - - const liveCapture = sourceSlice( - commandDockSource, - 'ref={bindLiveInputField}', - 'importantForAutofill="no"' - ) - expect(liveCapture).toContain('editable={canSend}') - }) - - it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { - const sendButton = sourceSlice( - commandDockSource, - 'styles.sendButton,', - 'accessibilityLabel="Send command"' - ) - expect(sendButton).toContain('disabled={!canSend}') - }) - - it('holds composed text when the return key submits offline', () => { - const handleSend = sourceSlice( - sendActionsSource, - 'async function handleSend()', - 'sendingRef.current = true' - ) - expect(handleSend).toContain('!canSend') - }) - - it('keeps the live/buffered mode toggle reachable offline', () => { - const modeToggle = sourceSlice( - commandDockSource, - 'liveInputEnabled && styles.accessoryKeyActive', - 'onPress={toggleLiveInput}' - ) - expect(modeToggle).toContain('disabled={!canCompose}') - }) - - it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { - const hookCall = sourceSlice( - runtimeSource, - 'useTerminalLiveInputCommit({', - 'setLiveInputCapture' - ) - expect(hookCall).toContain("connected: connState === 'connected'") - }) - +describe('terminal send request shape', () => { it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => { - // Live mirror, buffered send, and gesture arrows must all opt out of the - // connect wait — a parked send replays stale bytes into the PTY. Accessory - // keys get the same option inside terminal-live-accessory-raw-send.ts. - expect(sendActionsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') - expect(terminalInputSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') - const optionUses = [sendActionsSource, terminalInputSource].flatMap( - (source) => source.match(/TERMINAL_INPUT_SEND_OPTIONS/g) ?? [] - ).length - // Two owner imports plus one buffered, one live, and one gesture send. - expect(optionUses).toBe(5) + // A parked send replays stale bytes into the PTY once the socket comes back. expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) }) diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts deleted file mode 100644 index 9929f09c37d..00000000000 --- a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') -const sendActionsSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-send-actions.ts' -) - -// Why: iOS terminates an active keyboard-dictation (and IME) session whenever -// JS writes a value into the focused field that differs from the native text -// (RN applies it via setTextAndSelection / _setAttributedString). Terminal -// inputs therefore must echo the raw field text in their controlled value and -// apply dash normalization only on the send/mirror path. See stablyai/orca#7925. -describe('terminal iOS dictation write-back', () => { - it('does not write normalized text back into the buffered command input value', () => { - expect(commandDockSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}') - expect(commandDockSource).not.toContain('setInput((previousText) => normalizeTerminalTextInput') - }) - - it('still normalizes the buffered command text at send time', () => { - expect(sendActionsSource).toContain('normalizeTerminalTextInput(draft)') - }) -}) diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts deleted file mode 100644 index 573dcf04bbf..00000000000 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') - -describe('terminal iOS IME keyboard', () => { - it('does not force terminal inputs onto the ASCII-only iOS keyboard', () => { - expect(commandDockSource).not.toContain("'ascii-capable'") - expect(commandDockSource).not.toContain('"ascii-capable"') - }) - - it('subscribes live capture to onChange so the marked-text report survives', () => { - // onChangeText hands over only a string, discarding the preedit report that - // decides whether the text may reach the PTY at all. - expect(commandDockSource).toContain('onChange={handleLiveInputChange}') - expect(commandDockSource).not.toContain('onChangeText={handleLiveInputChange}') - }) - - it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { - expect(commandDockSource).not.toContain('textContentType="none"') - expect(commandDockSource).toContain('autoComplete="off"') - }) -}) diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 89830162921..40a03d14592 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -2,7 +2,6 @@ import { Terminal } from '@xterm/xterm' import { describe, expect, it, vi } from 'vitest' import { createTerminalDocumentScope } from './document/document-scope' -import { documentModuleSource } from './document/document-module-source.test-support' import { emitKeyboardAvoidanceMetrics } from './document/keyboard-avoidance-metrics' import { commitFitScale } from './document/fit-scale' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' @@ -193,45 +192,6 @@ describe('terminal keyboard-avoidance WebView metrics', () => { }) }) - it('refreshes metrics after every buffer geometry reset', () => { - // Four places change the buffer's geometry, and each owes a fresh emit after it: a stale - // content-bottom row is what lifts the keyboard over the wrong line. Read from each module's - // own source, so a fifth site added in a new module is not silently uncovered. - // A text-scale resize reports through the fit it schedules, whose commit emits (pinned below). - const directEmit = 'emitKeyboardAvoidanceMetrics(scope)' - const blocks = [ - ['terminal-init', 'export function resize(', directEmit], - ['reflow', 'export function reflow(', directEmit], - ['host-message-router', "} else if (msg.type === 'clear') {", directEmit], - ['text-scaling', 'export function applyTextScale(', 'applyFitScale(scope'] - ] as const - - for (const [module, opener, emit] of blocks) { - const source = documentModuleSource(module) - const start = source.indexOf(opener) - expect(start, `${module} no longer carries ${opener}`).toBeGreaterThanOrEqual(0) - const block = source.slice(start, source.indexOf('\n}', start)) - const emitAt = block.lastIndexOf(emit) - const geometryAt = block.includes('.resize(') - ? block.indexOf('.resize(') - : block.indexOf('.reset(') - expect(emitAt, `${module} does not emit metrics`).toBeGreaterThanOrEqual(0) - expect(emitAt, `${module} emits before it resizes`).toBeGreaterThan(geometryAt) - } - }) - - it('reports a text-scale change only through the fit it commits', () => { - // One emit site: a pinch release and a settings change both end in the fit commit, so the - // release carries no emit of its own and needs no word back on whether a refit is pending. - const textScaling = documentModuleSource('text-scaling') - const start = textScaling.indexOf('export function applyTextScale(') - const applyTextScale = textScaling.slice(start, textScaling.indexOf('\n}', start)) - expect(applyTextScale).not.toMatch(/return (true|false)/) - expect(documentModuleSource('surface-touch-gestures')).not.toContain( - 'emitKeyboardAvoidanceMetrics' - ) - }) - it('reports the row pitch as drawn, fit scale included, and none before a cell is measured', () => { // Desktop display mode keeps the desktop's rows and the fit shrinks the grid by width. const buffer = { cursorY: 0, viewportY: 0, type: 'normal', getLine: () => undefined } diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts deleted file mode 100644 index 565b355d771..00000000000 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') -const terminalRuntimeSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-runtime.ts' -) -const nativeChatSource = readMobileSessionRouteSource( - '../session/use-mobile-session-native-chat-dictation.ts' -) -const liveInputStatusSource = readFileSync( - new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url), - 'utf8' -) -const commandInputStylesSource = readFileSync( - new URL('../session/mobile-session-command-input-styles.ts', import.meta.url), - 'utf8' -) -const liveInputFocusSource = readFileSync( - new URL('./use-terminal-live-input-focus.ts', import.meta.url), - 'utf8' -) -const sendCompletionGenerationSource = readFileSync( - new URL('../session/use-mobile-send-completion-generation.ts', import.meta.url), - 'utf8' -) - -function liveInputBarBlock(): string { - const start = commandDockSource.indexOf('{liveInputEnabled ? (') - expect(start).toBeGreaterThanOrEqual(0) - const end = commandDockSource.indexOf(') : (', start) - expect(end).toBeGreaterThan(start) - return commandDockSource.slice(start, end) -} - -describe('terminal live input affordance', () => { - it('keeps the live status row wired as the keyboard focus control', () => { - const block = liveInputBarBlock() - - expect(block).toContain('onPress={focusLiveInput}') - expect(block).toContain('accessibilityRole="button"') - expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"') - expect(block).toContain( - 'accessibilityHint="Typed text is sent directly to the active terminal"' - ) - expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') - expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') - expect(block).toContain('showSoftInputOnFocus') - expect(block).toContain('liveInputText={liveInputCapture}') - expect(terminalRuntimeSource).toContain('useTerminalLiveInputFocus({') - expect(nativeChatSource).toContain('useMobileSendCompletionGeneration({') - expect(nativeChatSource).toContain('onBlur: resetLiveInputFocus') - expect(sendCompletionGenerationSource).toContain('return () => {') - expect(sendCompletionGenerationSource).toContain('onBlur()') - expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') - expect(liveInputFocusSource).toContain('lifecycleIdentity,') - expect(liveInputFocusSource).toContain('resetLiveInputFocus') - expect(liveInputFocusSource).toContain('keyboardHeight: context.keyboardHeight') - expect(liveInputFocusSource).toContain( - 'scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)' - ) - }) - - it('makes the live keyboard target visible instead of status-only chrome', () => { - expect(liveInputStatusSource).toContain("'Tap to show keyboard'") - expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") - expect(liveInputStatusSource).toContain('ellipsizeMode="head"') - expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') - expect(commandInputStylesSource).toContain('borderWidth: 1') - expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') - }) -}) diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 9b8a5b24b0e..51a349175be 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -1,59 +1,15 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import type { RpcResponse } from '../transport/types' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { terminalViewportUpdate } from './mobile-terminal-operations' import { isTerminalViewportRefitTargetCurrent, reduceTerminalFrameHeightRefit, resolveTerminalUpdateViewportCapability, type TerminalFrameHeightRefitEvent, - type TerminalFrameHeightRefitState, - type TerminalUpdateViewportCapability + type TerminalFrameHeightRefitState } from './terminal-viewport-refit-state' -const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8') -const sessionSource = [ - readMobileSessionRouteSource('../session/use-mobile-session-keyboard-state.ts'), - readMobileSessionRouteSource('../session/MobileSessionActiveContent.tsx'), - readMobileSessionRouteSource('../session/use-mobile-session-terminal-webview.ts') -].join('\n') - describe('terminal viewport refit', () => { - it('refits when the window dimensions change (fold/unfold, rotation)', () => { - // Why: a PTY fitted on the folded cover screen must be re-measured when - // the window grows, or the terminal renders in a fraction of the display. - expect(hookSource).toContain('useWindowDimensions()') - const start = hookSource.indexOf('const { width: windowWidth, height: windowHeight }') - expect(start).toBeGreaterThanOrEqual(0) - const resizeEffect = hookSource.slice(start) - expect(resizeEffect).toContain('viewportMeasuredRef.current = false') - expect(resizeEffect).toContain('scheduleViewportRefit()') - expect(resizeEffect).toContain( - '[windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit]' - ) - }) - - it('still refits when the tab strip toggles visibility', () => { - const start = hookSource.indexOf('const prevTabStripVisibleRef') - expect(start).toBeGreaterThanOrEqual(0) - const tabEffect = hookSource.slice(start, hookSource.indexOf('useWindowDimensions()')) - expect(tabEffect).toContain('viewportMeasuredRef.current = false') - expect(tabEffect).toContain('scheduleViewportRefit()') - }) - - it('refits the PTY when terminal text scale changes', () => { - // Why: mobile text size must change the real PTY grid, not just scale pixels - // in the WebView, or wrapped CLI output diverges from what the shell sees. - const start = hookSource.indexOf('const prevTextScaleRef = useRef(textScale)') - expect(start).toBeGreaterThanOrEqual(0) - const textScaleEffect = hookSource.slice(start, start + 600) - expect(textScaleEffect).toContain('prevTextScaleRef.current === textScale') - expect(textScaleEffect).toContain('viewportMeasuredRef.current = false') - expect(textScaleEffect).toContain('scheduleViewportRefit()') - expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]') - }) - it('coalesces keyboard-visible frame-height churn into one refit after close', () => { let state: TerminalFrameHeightRefitState = { frameHeight: 600, @@ -84,17 +40,6 @@ describe('terminal viewport refit', () => { expect(refitCount).toBe(2) }) - it('routes imperative height notifications through the keyboard-aware reducer', () => { - const start = hookSource.indexOf('const notifyFrameHeightRefitEvent = useCallback(') - expect(start).toBeGreaterThanOrEqual(0) - const notifier = hookSource.slice(start, start + 1_300) - expect(notifier).toContain('reduceTerminalFrameHeightRefit(') - expect(notifier).toContain("{ type: 'frame-height', height }") - expect(notifier).toContain("{ type: 'keyboard-visibility', visible }") - expect(notifier).toContain('viewportMeasuredRef.current = false') - expect(notifier).toContain('scheduleViewportRefit({ heightOriginated: true })') - }) - it('re-defers a height refit if the keyboard reopens before the debounce fires', () => { // Settle while the keyboard is up -> deferred (pending), no refit. let r = reduceTerminalFrameHeightRefit( @@ -126,95 +71,6 @@ describe('terminal viewport refit', () => { expect(ran.state.pending).toBe(false) }) - it('re-checks the keyboard at fire time only for height-originated refits', () => { - const start = hookSource.indexOf('refitTimerRef.current = setTimeout(') - expect(start).toBeGreaterThanOrEqual(0) - const timerBody = hookSource.slice(start, start + 900) - // The height flag scopes the guard; forced/width refits stay unguarded. - expect(timerBody).toContain('if (heightOriginatedRefitRef.current)') - expect(timerBody).toContain("type: 'refit-committed'") - expect(timerBody).toContain('if (!decision.shouldRefit)') - }) - - it('suppresses refits while native chat covers the active terminal', () => { - // Why: native chat renders the transcript, not the grid — a refit there would - // reflow the desktop PTY to phone dims the user never sees. - const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(') - const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart) - const fitIndex = hookSource.indexOf('ref.fitDimensions(', timerStart) - expect(timerStart).toBeGreaterThanOrEqual(0) - expect(coveredCheck).toBeGreaterThan(timerStart) - expect(fitIndex).toBeGreaterThan(coveredCheck) - expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef') - }) - - it('is wired into the session screen', () => { - expect(sessionSource).toContain('useTerminalViewportRefit({') - expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') - expect(sessionSource).toContain('textScale: terminalTextScale') - expect(sessionSource).toContain('connState,') - expect(sessionSource).toContain('notifyTerminalFrame({ width, height })') - expect(sessionSource).toContain('notifyTerminalFrameHeight(Math.round(frame.height))') - expect(sessionSource).toContain('notifyTerminalFrameWidth()') - // One seam for both facts: they come apart for a floating keyboard, which is open yet covers - // nothing the screen has to lift for. - expect(sessionSource).toContain('const softKeyboard = useSoftKeyboard()') - expect(sessionSource).toContain('notifyKeyboardVisibility(softKeyboard.visible)') - expect(sessionSource).toContain('setKeyboardHeight(softKeyboard.height)') - }) - - it('does not rerender SessionScreen for frame-height-only layout changes', () => { - // The imperative notifier keeps a dock-settling burst off React's render path. - expect(sessionSource).not.toContain('setTerminalFrameHeight') - expect(sessionSource).not.toContain('const [terminalFrameHeight,') - }) - - it('defers height-only window resizes while the keyboard is visible', () => { - const start = hookSource.indexOf('const prevWindowDimsRef') - const windowEffect = hookSource.slice(start, start + 1_100) - expect(windowEffect).toContain( - 'prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible' - ) - }) - - it('forces a refit on iOS foreground and connection recovery', () => { - const foregroundEffect = hookSource.slice( - hookSource.indexOf("if (Platform.OS !== 'ios')"), - hookSource.indexOf('const previousConnStateRef') - ) - const reconnectEffect = hookSource.slice( - hookSource.indexOf('const previousConnStateRef'), - hookSource.indexOf('disposedRef.current = false') - ) - - expect(foregroundEffect).toContain("AppState.addEventListener('change'") - expect(foregroundEffect).toContain('viewportMeasuredRef.current = false') - expect(foregroundEffect).toContain('scheduleForcedViewportRefit()') - expect(reconnectEffect).toContain("connState !== 'connected'") - expect(reconnectEffect).toContain('viewportMeasuredRef.current = false') - expect(reconnectEffect).toContain('scheduleForcedViewportRefit()') - }) - - it('bypasses the equal-dimensions guard for resume and reconnect refits', () => { - expect(hookSource).toContain('const forceRefit = forceNextRefitRef.current') - expect(hookSource).toContain( - 'if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows)' - ) - const forceRead = hookSource.indexOf('const forceRefit = forceNextRefitRef.current') - const updateViewport = hookSource.indexOf('terminalViewportUpdate.request(rpc,') - expect(forceRead).toBeGreaterThanOrEqual(0) - expect(updateViewport).toBeGreaterThan(forceRead) - }) - - it('prefers the in-place updateViewport RPC over resubscribe', () => { - const rpcIndex = hookSource.indexOf('terminalViewportUpdate.request(rpc,') - const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)') - const resubscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)') - expect(rpcIndex).toBeGreaterThanOrEqual(0) - expect(cacheUpdateIndex).toBeGreaterThan(rpcIndex) - expect(resubscribeIndex).toBeGreaterThan(rpcIndex) - }) - it('falls back to legacy resubscribe when an older desktop lacks updateViewport', () => { const unsupported = { id: 'old-host', @@ -229,52 +85,8 @@ describe('terminal viewport refit', () => { error: { code: 'temporary_failure', message: 'retryable' } }) ).toBe('unknown') - - let capability: TerminalUpdateViewportCapability = 'unknown' - let probeCount = 0 - for (let refit = 0; refit < 10; refit += 1) { - if (capability === 'unsupported') { - continue - } - probeCount += 1 - capability = resolveTerminalUpdateViewportCapability(unsupported) - } - expect(probeCount).toBe(1) - - const responseCheckIndex = hookSource.indexOf('if (outcome?.updated)') - const unsubscribeIndex = hookSource.indexOf('unsubscribeTerminal(handle)', responseCheckIndex) - const subscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)', unsubscribeIndex) - expect(responseCheckIndex).toBeGreaterThanOrEqual(0) - expect(unsubscribeIndex).toBeGreaterThan(responseCheckIndex) - expect(subscribeIndex).toBeGreaterThan(unsubscribeIndex) - expect(hookSource).toContain("updateViewportCapabilityRef.current !== 'unsupported'") - expect(hookSource).toContain("updateViewportCapabilityRef.current = 'unknown'") - }) - - it('reflows the local xterm scrollback after a successful updateViewport', () => { - // Why: updateViewport may only record an informational mobile viewport in - // desktop mode. Reflow local scrollback only after the server says it - // actually applied phone-fit to the PTY. - const appliedIndex = hookSource.indexOf('if (outcome.applied)') - const reflowIndex = hookSource.indexOf('ref.reflow(dims.cols, dims.rows, frame)') - const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)') - // Assert each anchor exists before ordering: a missing marker yields -1 and would - // let the ordering comparisons pass vacuously. - expect(appliedIndex).toBeGreaterThanOrEqual(0) - expect(cacheUpdateIndex).toBeGreaterThanOrEqual(0) - expect(reflowIndex).toBeGreaterThanOrEqual(0) - expect(reflowIndex).toBeGreaterThan(appliedIndex) - expect(reflowIndex).toBeGreaterThan(cacheUpdateIndex) - }) - - it('checks refit freshness after updateViewport resolves before side effects', () => { - // Why: rapid dock/sidebar resizing can complete RPCs out of order; a stale - // response must not update the viewport cache or locally reflow the old dims. - const responseIndex = hookSource.indexOf('terminalViewportUpdate.request(rpc,') - const postRpcCurrentIndex = hookSource.indexOf('if (!isCurrentTarget())', responseIndex) - const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)') - expect(postRpcCurrentIndex).toBeGreaterThan(responseIndex) - expect(postRpcCurrentIndex).toBeLessThan(cacheUpdateIndex) + // A method_not_found refusal latches the capability, so a refit stops re-probing. + expect(resolveTerminalUpdateViewportCapability(unsupported)).toBe('unsupported') }) it('only treats updateViewport as applied when the runtime updated the subscriber', () => { diff --git a/mobile/src/terminal/terminal-webview-consumer-census.test.ts b/mobile/src/terminal/terminal-webview-consumer-census.test.ts deleted file mode 100644 index cb57e4a4755..00000000000 --- a/mobile/src/terminal/terminal-webview-consumer-census.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Nothing above the terminal component knows which of the two it has. - * - * The page's `TerminalWebView.web.tsx` is resolved by the bundler, not chosen by a caller, so the - * whole substitution rests on every consumer holding only `terminal-webview-contract` — the props - * and the handle — and on the one that renders the component naming it without an extension. A - * consumer that reached into `TerminalWebView.tsx` for a type, or imported `terminal-webview-html` - * for the document string, would work natively and break on the page in a way no native test sees. - * - * Scanned rather than listed, so a new consumer joins the rule by existing. - */ - -const terminalDir = import.meta.dirname -const sessionDir = join(terminalDir, '..', 'session') - -/** The modules that may reach into the component's own file or the document's HTML. */ -const ALLOWED_INSIDE_TERMINAL = new Set([ - 'TerminalWebView.tsx', - 'TerminalWebView.web.tsx', - 'terminal-web-document-mount.ts', - 'terminal-webview-html.ts', - 'terminal-webview-html.web.ts', - // Test scaffolding that drives the document's own modules; it is not shipped in either build. - 'terminal-webview-mouse-test-harness.ts' -]) - -const FORBIDDEN_ABOVE_THE_CONTRACT = [ - /from '(\.\.\/terminal|\.)\/TerminalWebView\.(web\.)?tsx?'/, - /from '(\.\.\/terminal|\.)\/terminal-webview-html'/, - /from '(\.\.\/terminal|\.)\/terminal-webview-engine(-css)?\.generated'/, - /from '(\.\.\/terminal|\.)\/document\// -] - -function productModules(directory: string): string[] { - return readdirSync(directory) - .filter((name) => /\.tsx?$/.test(name)) - .filter((name) => !name.includes('.test') && !name.includes('.test-support')) - .sort() -} - -function offenders(directory: string, skip: (name: string) => boolean): string[] { - const found: string[] = [] - for (const name of productModules(directory)) { - if (skip(name)) { - continue - } - const source = readFileSync(join(directory, name), 'utf8') - for (const pattern of FORBIDDEN_ABOVE_THE_CONTRACT) { - if (pattern.test(source)) { - found.push(`${name}: ${pattern.source}`) - } - } - } - return found -} - -describe('the terminal component contract', () => { - it('is all that src/session imports of the terminal', () => { - expect(offenders(sessionDir, () => false)).toEqual([]) - // The precondition: a scan that read no session module would report nothing either. - const rendering = readFileSync(join(sessionDir, 'TerminalPaneView.tsx'), 'utf8') - expect(rendering).toContain("from '../terminal/TerminalWebView'") - expect(rendering).toContain("from '../terminal/terminal-webview-contract'") - }) - - it('is all that the terminal directory itself imports, outside the component and its document', () => { - expect(offenders(terminalDir, (name) => ALLOWED_INSIDE_TERMINAL.has(name))).toEqual([]) - expect(productModules(terminalDir).length).toBeGreaterThan(40) - }) - - it('would report a consumer that named the component file', () => { - // The scan tested on the text it is meant to refuse, so an empty offender list above is a - // measurement rather than a regex that matches nothing. - const planted = "import { TerminalWebView } from '../terminal/TerminalWebView.tsx'\n" - expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(planted))).toBe(true) - const html = "import { XTERM_HTML } from '../terminal/terminal-webview-html'\n" - expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(html))).toBe(true) - const extensionless = "import { TerminalWebView } from '../terminal/TerminalWebView'\n" - expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(extensionless))).toBe(false) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 3f3aab326fe..96a4327f090 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -1,6 +1,4 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it, vi } from 'vitest' @@ -8,15 +6,8 @@ import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated' import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { createTerminalDocumentScope } from './document/document-scope' import { attachWebglAddon, startWebglRecovery } from './document/webgl-recovery' -import { - documentModuleSource, - documentSourceText -} from './document/document-module-source.test-support' import { XTERM_HTML } from './terminal-webview-html' -// The document's own source, so a rule about what the document does is read where it is written. -const terminalHtmlSource = documentSourceText() - function createWebglRecoveryHarness(failSecondAttach = false) { const timers: Array<() => void> = [] const addons: Array<{ @@ -151,58 +142,6 @@ describe('terminal WebView bundled engine', () => { expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i) }) - it('reports WebView message handler failures instead of swallowing them', () => { - const start = terminalHtmlSource.indexOf('function handleIncomingMessage') - // Bounded by the next declaration in the same module: ruling 24 took the resize listener out - // of the bridge, so the handler is followed by the start that installs the transport. - const end = terminalHtmlSource.indexOf('function startMessageBridge', start) - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - const handlerSource = terminalHtmlSource.slice(start, end) - - expect(handlerSource).toContain('reportEngineError(') - expect(handlerSource).toContain("'terminal init failed'") - expect(handlerSource).toContain("'terminal message failed'") - expect(handlerSource).not.toContain('catch(ex) {}') - }) - - it('classifies runtime errors by a document-scoped ever-ready latch', () => { - // Why: init() flips `ready` false on every re-init (live width reflow keeps the - // old surface visible meanwhile), so the fatal default and the init-catch must - // key off `everReady` — otherwise a transient reflow error blanks a live - // terminal behind the fatal overlay. The latch stays set for the document. - // Ruling 21: the latch's initial value is in the scope factory, not in a parse-time write. - expect(terminalHtmlSource).toContain('everReady: false,') - expect(terminalHtmlSource).toContain('scope.everReady = true') - expect(terminalHtmlSource).toContain('fatal === undefined ? !scope.everReady : !!fatal') - expect(terminalHtmlSource).toContain("msg && msg.type === 'init' && !scope.everReady") - expect(terminalHtmlSource).not.toMatch(/fatal === void 0 \? !scope\.ready\b/) - }) - - it('bounds error capture and non-fatal reporting on a degraded engine', () => { - // Why: a constructed-but-broken engine can throw per render frame; both - // onerror capture sites must cap the buffer and non-fatal notifies must - // stop flooding RN while fatal reports always emit. - // Both sites: the document's own reporter, and the shell's inline handler that catches what - // fails before the document has run at all. - // `dirname`, not the module URL: a DOM-environment case has no `file:` URL to convert. - const shell = readFileSync( - join(import.meta.dirname, 'terminal-webview-html', 'document-shell.ts'), - 'utf8' - ) - // The shell's buffer is a global because it is older than any document; the document appends to - // it through the seam, so the two sites now spell the same cap over the same list differently. - expect(shell).toContain('window.__engineErrors.length < 20') - expect(terminalHtmlSource).toContain('const captured = scope.capturedEngineErrors()') - expect(terminalHtmlSource).toContain('if (captured.length < 20) {') - // The global is read in one place, the seam's own default, and the reporter no longer names it. - expect(documentModuleSource('host-notify')).not.toContain('window.__engineErrors') - expect(documentModuleSource('document-host-seams')).toContain( - 'window.__engineErrors = window.__engineErrors ?? []' - ) - expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5') - }) - it('recreates WebGL once after context loss, then stays on the DOM renderer', () => { const { addons, logged, term, timers } = createWebglRecoveryHarness() diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 48de712bcaa..fe090b1436e 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -6,7 +6,6 @@ import { resetTerminalDataReplyAuthority, resumeTerminalDataReplyAuthority } from './document/query-reply' -import { documentModuleSource } from './document/document-module-source.test-support' type QueryReplyGate = { forward: (data: string) => void @@ -50,32 +49,6 @@ function createQueryReplyGate(notify: (message: unknown) => void): { } describe('mobile terminal query replies', () => { - it('forwards xterm-generated data only after initial replay drains', () => { - const bridge = documentModuleSource('query-reply') - const init = documentModuleSource('terminal-init') - // The listener is armed by the bridge, and what it forwards is the gate's own call. - expect(bridge).toContain('term.onData(') - expect(bridge).toContain('forwardTerminalDataReply(scope, data)') - // The terminal takes keystrokes from the host, never from its own textarea: stdin is enabled so - // xterm generates replies, and the textarea is read-only so the phone's keyboard cannot type. - expect(init).toContain('disableStdin: false') - // Both are the bridge's own doing, where the listener it arms is: it takes the key handler and - // the textarea in the same breath. - expect(bridge).toContain('term.attachCustomKeyEventHandler(') - expect(bridge).toContain('term.textarea.readOnly = true') - }) - - it('mutes a replacement terminal until its own replay drains', () => { - const init = documentModuleSource('terminal-init') - const initIndex = init.indexOf('export function init(') - const disableIndex = init.indexOf('resetTerminalDataReplyAuthority(scope)', initIndex) - const enableIndex = init.indexOf('attachTerminalQueryReplyBridge(scope,', disableIndex) - - expect(initIndex).toBeGreaterThanOrEqual(0) - expect(disableIndex).toBeGreaterThan(initIndex) - expect(enableIndex).toBeGreaterThan(disableIndex) - }) - it('suppresses replay, then forwards live queries queued behind its boundary', () => { const messages: unknown[] = [] const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) @@ -117,10 +90,5 @@ describe('mobile terminal query replies', () => { gate.forward('\x1b[3;4R') expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) - const router = documentModuleSource('host-message-router') - const clearStart = router.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = router.indexOf("} else if (msg.type === 'reset-zoom')", clearStart) - expect(clearStart).toBeGreaterThanOrEqual(0) - expect(router.slice(clearStart, clearEnd)).toContain('resumeTerminalDataReplyAuthority(scope)') }) }) diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts deleted file mode 100644 index 82948f541eb..00000000000 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { - documentModuleSource, - documentSourceText -} from './document/document-module-source.test-support' - -const DOCUMENT_SOURCE = documentSourceText() - -// The reflow logic is the document's; the message dispatch and the handle wiring are the -// component's. Both are read as source, because the document is source now. -const reflowSource = documentModuleSource('reflow') -// The handle is built by the controller both components share, which is where the wiring is read. -const handleSource = readFileSync( - new URL('./use-terminal-webview-controller.ts', import.meta.url), - 'utf8' -) - -function reflowFnBody(): string { - const start = reflowSource.indexOf('export function reflow(scope: TerminalDocumentScope') - expect(start).toBeGreaterThanOrEqual(0) - return reflowSource.slice(start) -} - -describe('terminal WebView reflow', () => { - it('skips the alternate screen so TUI snapshots are not mutated', () => { - // Why: alt-screen snapshots are repainted by the PTY; a local resize there - // can drop SGR attributes (white text). Reflow must early-return. - expect(reflowFnBody()).toContain('if (!scope.term || isAlternateBufferActive(scope)) {') - }) - - it('rewraps the local buffer via term.resize to the new cols', () => { - expect(reflowFnBody()).toContain('scope.term.resize(nextCols, nextRows)') - }) - - it('preserves the user scroll position across the rewrap', () => { - const body = reflowFnBody() - // At the live bottom -> stay pinned; scrolled up -> hold distance-from-bottom. - expect(body).toContain('const wasAtBottom = buffer.viewportY >= buffer.baseY') - expect(body).toContain('scope.term.scrollToBottom()') - expect(body).toContain('rewrapped.baseY - distanceFromBottom - rewrapped.viewportY') - }) - - it('is no-op when the dimensions are unchanged', () => { - expect(reflowFnBody()).toContain( - 'if (nextCols === scope.term.cols && nextRows === scope.term.rows) {' - ) - }) - - it('is dispatched by the reflow WebView message and exposed on the handle', () => { - expect(DOCUMENT_SOURCE).toContain("} else if (msg.type === 'reflow') {") - expect(DOCUMENT_SOURCE).toContain('reflow(scope, msg.cols!, msg.rows!)') - expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows, frame })") - }) - - it('does not locally resize hidden WebViews to a one-column grid', () => { - // The floor belongs to the one fit the app and the document share. - const gridFitSource = readFileSync(new URL('./terminal-grid-fit.ts', import.meta.url), 'utf8') - expect(gridFitSource).toContain('export const MIN_FIT_COLS = 20') - expect(gridFitSource).toContain('if (!(cols >= MIN_FIT_COLS)) {') - }) -}) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts deleted file mode 100644 index bb0cc649412..00000000000 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { webviewPageSource } from './document/document-module-source.test-support' - -const DOCUMENT_SOURCE = webviewPageSource() - -// The RN wrapper and the pending-message queue are TypeScript; everything the WebView runs is the -// generated document. Concatenated so assertions resolve regardless of file. -const source = - readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') + - readFileSync(new URL('./use-terminal-webview-controller.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-ready-promises.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html/document-markup.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html/document-style.ts', import.meta.url), 'utf8') + - DOCUMENT_SOURCE -const sessionSource = readFileSync( - new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), - 'utf8' -) -const sessionHelperSource = readFileSync( - new URL('../session/mobile-session-route-helpers.ts', import.meta.url), - 'utf8' -) - -function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('TerminalWebView scroll routing', () => { - it('keeps Android touch drags inside the terminal WebView', () => { - expect(source).toContain('nestedScrollEnabled') - }) - - it('maps a downward pull at the bottom to older scrollback rows', () => { - expect(source).toContain('const deltaY = scope.touchGesture.lastY - y') - expect(source).toContain('scope.smoothScrollOffsetY -= deltaY') - expect(source).toContain( - 'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH)' - ) - - const nextViewportY = simulateNormalBufferPull({ - baseY: 120, - viewportY: 120, - startY: 300, - endY: 340, - cellHeight: 20 - }) - - expect(nextViewportY).toBe(118) - }) - - it('routes alternate-screen and mouse-aware scroll before smooth normal scroll', () => { - expect(source).toContain( - 'return isWheelMouseTrackingMode(getMouseTrackingMode(scope)) || isAlternateBufferActive(scope)' - ) - - const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener(\n 'touchmove'", - '{ capture: true, passive: false }' - ) - expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput(scope))')).toBeLessThan( - touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(scope, deltaY))') - ) - expect(touchMoveBlock).toContain('routeScrollLines(scope, lines, x, y)') - - const momentumBlock = sliceBetween( - 'function momentumStep(frameTime: number) {', - 'if (Math.abs(vel) > MIN_VEL)' - ) - expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput(scope))')).toBeLessThan( - momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(scope, delta))') - ) - expect(momentumBlock).toContain( - 'routeScrollLines(scope, lines, scope.touchGesture.lastX, scope.touchGesture.lastY)' - ) - }) - - it('does not rubber-band normal scroll at scrollback edges', () => { - expect(source).toContain('export function canScrollNormalBufferDelta(') - const smoothScrollBlock = sliceBetween( - 'export function applyNormalBufferScrollDelta(', - 'export function enqueueNormalBufferScrollDelta(' - ) - expect(smoothScrollBlock).toContain('if (!canScrollNormalBufferDelta(scope, deltaY))') - expect(smoothScrollBlock).toContain('resetSmoothScrollOffset(scope)') - expect(smoothScrollBlock).toContain('return false') - expect(smoothScrollBlock).toContain('return true') - - const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener(\n 'touchmove'", - '{ capture: true, passive: false }' - ) - expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(scope, deltaY))') - expect(touchMoveBlock).toContain('scope.touchGesture.velY = 0') - - const momentumBlock = sliceBetween( - 'function momentumStep(frameTime: number) {', - 'if (Math.abs(vel) > MIN_VEL)' - ) - expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(scope, delta))') - expect(momentumBlock).toContain('scope.touchGesture.momentumId = null') - }) - - it('coalesces normal touch scroll row commits onto animation frames', () => { - const enqueueBlock = sliceBetween( - 'export function enqueueNormalBufferScrollDelta(', - 'export function resetSmoothScrollOffset(' - ) - expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY') - expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {') - // Ruling 21: every document frame goes through the scope's registry so dispose can take it - // back; the id is still held here, which is what the reset below cancels. - expect(enqueueBlock).toContain( - 'scope.normalScrollFrameId = scheduleDocumentFrame(scope, function () {' - ) - expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(scope, delta)') - - const resetBlock = sliceBetween( - 'export function resetSmoothScrollOffset(', - 'export function stopNormalBufferSmoothScroll(' - ) - expect(resetBlock).toContain('scope.pendingNormalScrollDeltaY = 0') - expect(resetBlock).toContain('cancelAnimationFrame(scope.normalScrollFrameId)') - }) - - it('bounds native-side pending WebView writes while preserving control messages', () => { - expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000') - expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096') - expect(source).toContain('let pendingWriteBytes = 0') - expect(source).toContain('let pendingWriteCount = 0') - expect(source).toContain('const queue = (msg: TerminalWebViewCommand)') - expect(source).toContain('pendingWriteCount > MAX_PENDING_WEB_WRITE_MESSAGES') - expect(source).toContain("candidate.type === 'write'") - expect(source).toContain('pendingMessages.queue(msg)') - expect(source).toContain('pendingMessages.clear()') - }) - - it('clears the ready await timer when the real response wins', () => { - // C7.5 moved the promise into `terminal-webview-ready-promises.ts`, which both components - // reach through the controller. - const readyBlock = sliceBetween('async function awaitReady()', 'return { armReady') - expect(readyBlock).toContain('clearTimeout(timeout)') - expect(readyBlock).toContain('void pending.finally') - }) - - it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => { - expect(source).toContain('
') - expect(source).toContain('.xterm .xterm-viewport::-webkit-scrollbar') - expect(source).toContain('.xterm .xterm-scrollable-element > .xterm-scrollbar') - expect(source).toContain('overflow-y: hidden !important') - expect(source).toContain('display: none !important') - expect(source).toContain('export function updateScrollIndicator(') - expect(source).toContain('buffer.viewportY / maxViewportY') - expect(source).not.toContain('fractionalRows') - expect(source).toContain('scrollThumb.style.transform =') - expect(source).toContain('updateScrollIndicator(scope, true)') - }) - - it('does not apply fractional smooth scroll transforms to terminal content', () => { - const updateTransformBlock = sliceBetween( - 'export function updateTransform(', - 'export function updateScrollIndicator(' - ) - expect(updateTransformBlock).toContain( - "'translate(' + scope.panX + 'px,' + scope.panY + 'px) scale(' + getTotalScale(scope) + ')'" - ) - expect(source).not.toContain("querySelector('.xterm-screen')") - expect(source).not.toContain('updateTerminalScreenTransform') - expect(updateTransformBlock).not.toContain('getVisualPanY() + "px) scale("') - expect(updateTransformBlock).not.toContain('smoothScrollOffsetY') - }) - - it('smooths velocity samples and uses lower friction for mobile momentum', () => { - expect(source).toContain('export function updateTouchVelocity(') - expect(source).toContain('scope.touchGesture.velY * 0.55 + instantVelocity * 0.45') - expect(source).toContain('const FRICTION = 0.972') - expect(source).toContain('const MIN_VEL = 0.012') - // #21687: the decay is per elapsed millisecond, so a 120 Hz screen coasts the same distance. - expect(source).toContain('let lastMomentumTime = performance.now()') - expect(source).toContain( - 'const elapsed = Math.max(1, Math.min(50, frameTime - lastMomentumTime))' - ) - expect(source).toContain('vel *= FRICTION ** (elapsed / 16)') - expect(source).toContain('const delta = vel * elapsed') - }) - - it('keeps selection edge autoscroll active and extends the dragged endpoint', () => { - const startBlock = sliceBetween( - 'export function startEdgeScroll(', - 'export function stopEdgeScroll(' - ) - expect(startBlock.indexOf('stopEdgeScroll(scope)')).toBeLessThan( - startBlock.indexOf('scope.edgeScrollDir = dir') - ) - expect(startBlock.indexOf('scope.term.scrollLines(scope.edgeScrollDir)')).toBeLessThan( - startBlock.indexOf('syncEdgeScrollSelectionEndpoint(scope)') - ) - - const dragMoveBlock = sliceBetween( - 'export function handleDragMove(', - 'function attachSurfaceEventHandlers(' - ) - expect(dragMoveBlock).toContain('scope.edgeScrollClientX = clientX') - expect(dragMoveBlock).toContain('scope.edgeScrollClientY = clientY') - expect(dragMoveBlock).toContain( - 'syncSelectionHandleToViewportPoint(scope, handle, clientX, clientY)' - ) - }) - - it('allows x10 mouse gesture reports through the mobile session gate', () => { - expect(sessionHelperSource).toContain('function isGestureMouseTrackingMode') - expect(sessionHelperSource).toContain( - "return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'" - ) - - const inputBlockStart = sessionSource.indexOf('const handleTerminalInput = useCallback') - expect(inputBlockStart).toBeGreaterThanOrEqual(0) - const inputBlockEnd = sessionSource.indexOf( - 'async function handleClearTerminal', - inputBlockStart - ) - expect(inputBlockEnd).toBeGreaterThan(inputBlockStart) - const inputBlock = sessionSource.slice(inputBlockStart, inputBlockEnd) - expect(inputBlock).toContain('!isGestureMouseTrackingMode(modes?.mouseTrackingMode)') - expect(inputBlock).toContain('const sequenceCount = countTerminalGestureInputSequences(bytes)') - expect(inputBlock.indexOf('countTerminalGestureInputSequences')).toBeLessThan( - inputBlock.indexOf('enqueueTerminalGestureInput') - ) - }) -}) - -function simulateNormalBufferPull({ - baseY, - viewportY, - startY, - endY, - cellHeight -}: { - baseY: number - viewportY: number - startY: number - endY: number - cellHeight: number -}): number { - const deltaY = startY - endY - if (deltaY > 0 ? viewportY >= baseY : viewportY <= 0) { - return viewportY - } - const smoothScrollOffsetY = -deltaY - const lines = Math.trunc(-smoothScrollOffsetY / cellHeight) - const applied = Math.max(lines, -viewportY) - return viewportY + applied -} diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 20b2d2a0afd..29ba4bfcd13 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -1,19 +1,8 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { createTerminalDocumentScope } from './document/document-scope' import { normalizeStatusDotPresentation } from './document/write-queue' import { startTextScaling } from './document/text-scaling' -import { webviewPageSource } from './document/document-module-source.test-support' - -const terminalWebViewSource = readFileSync(join(import.meta.dirname, 'TerminalWebView.tsx'), 'utf8') -const terminalHtmlDocumentShellSource = readFileSync( - join(import.meta.dirname, 'terminal-webview-html', 'document-shell.ts'), - 'utf8' -) -// The document's own source, which is what the WebView runs once bundled. -const terminalHtmlSource = webviewPageSource() function normalizeStatusDotChunks(chunks: string[]) { const scope = createTerminalDocumentScope() @@ -46,45 +35,6 @@ function resolveTerminalFontFamily(navigatorValue: { } describe('TerminalWebView text zoom', () => { - it('pins textZoom to 100 so Android system font scale cannot inflate glyphs past xterm cell metrics', () => { - const start = terminalWebViewSource.indexOf('', start) - expect(end).toBeGreaterThan(start) - const webViewProps = terminalWebViewSource.slice(start, end) - expect(webViewProps).toContain('textZoom={100}') - }) - - it('keeps the HTML source object stable so parent renders do not reload xterm', () => { - const start = terminalWebViewSource.indexOf('', start) - expect(end).toBeGreaterThan(start) - const webViewProps = terminalWebViewSource.slice(start, end) - // One source object per view, pinned at mount. - expect(terminalWebViewSource).toContain('const [source] = useState(() =>') - expect(webViewProps).toContain('source={source}') - }) - - it('forces the Claude status dot to text presentation before xterm writes', () => { - expect(terminalHtmlSource).toContain('font-variant-emoji: text') - // The dot and its two selectors are the write queue's own constants, and the pattern is a - // literal because a construction at a module's top level would be parse-time work (ruling 20). - expect(terminalHtmlSource).toContain("const CLAUDE_STATUS_DOT = '\\u23fa'") - expect(terminalHtmlSource).toContain("const TEXT_PRESENTATION_SELECTOR = '\\ufe0e'") - expect(terminalHtmlSource).toContain("const EMOJI_PRESENTATION_SELECTOR = '\\ufe0f'") - expect(terminalHtmlSource).toContain( - 'const CLAUDE_STATUS_DOT_PATTERN = /\\u23fa[\\ufe0e\\ufe0f]*/g' - ) - expect(terminalHtmlSource).toContain('export function normalizeStatusDotPresentation(') - expect(terminalHtmlSource).toContain( - 'CLAUDE_STATUS_DOT_PATTERN,\n CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR' - ) - expect(terminalHtmlSource).toContain( - 'scope.writeQueue.push(normalizeStatusDotPresentation(scope, data))' - ) - }) - it('normalizes Claude status dots idempotently across write chunks', () => { const dot = String.fromCharCode(0x23fa) const textSelector = String.fromCharCode(0xfe0e) @@ -111,36 +61,6 @@ describe('TerminalWebView text zoom', () => { ) }) - it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => { - const initStart = terminalHtmlSource.indexOf('export function init(') - const initReplay = terminalHtmlSource.indexOf( - 'const replayData = normalizeInitialData(initialData)' - ) - const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'reset-zoom')", clearStart) - expect(initStart).toBeGreaterThanOrEqual(0) - expect(initReplay).toBeGreaterThan(initStart) - expect(clearStart).toBeGreaterThanOrEqual(0) - expect(clearEnd).toBeGreaterThan(clearStart) - expect(terminalHtmlSource.slice(initStart, initReplay)).toContain( - 'scope.statusDotPendingSelector = false' - ) - expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain( - 'scope.statusDotPendingSelector = false' - ) - }) - - it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') - expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') - const open = terminalHtmlSource.indexOf('term.open(scope.surface!)') - const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") - const replay = terminalHtmlSource.indexOf("enqueueWrite(scope, ESC + '[0m' + replayData)") - expect(open).toBeGreaterThanOrEqual(0) - expect(unicode).toBeGreaterThan(open) - expect(replay).toBeGreaterThan(unicode) - }) - const IOS_IPHONE_NAVIGATOR = { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15', platform: 'iPhone', diff --git a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts index 39615ef3692..3dd290fa1ff 100644 --- a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' @@ -7,15 +6,6 @@ import { TERMINAL_WRITE_FLUSH_WINDOW_MS } from './terminal-write-coalescer' -const webViewSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') -// C7.5 moved everything that is not `react-native-webview` into the controller both components -// share, so the coalescer's boundaries are read there; the component is still read for the two -// WebView lifecycle events that reach it. -const controllerSource = readFileSync( - new URL('./use-terminal-webview-controller.ts', import.meta.url), - 'utf8' -) - // Simulates TerminalWebView's postMessage: ready → deliver, not ready → queue. // There is no React render harness in the node environment, so the boundary // invariants are gated here against the same pending-queue module the component uses. @@ -110,73 +100,4 @@ describe('terminal write coalescer boundaries', () => { { type: 'write', data: 'still-current-document' } ]) }) - - it('routes handle.write through the coalescer whose delivery posts the write command', () => { - expect(controllerSource).toContain( - "createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data }))" - ) - const writeStart = controllerSource.indexOf('write(data: string) {') - expect(writeStart).toBeGreaterThanOrEqual(0) - const writeBody = controllerSource.slice(writeStart, writeStart + 120) - expect(writeBody).toContain('writeCoalescer.write(data)') - expect(writeBody).not.toContain('postMessage') - }) - - it('clears the coalescer before posting init and clear (snapshot supersession)', () => { - // Anchor on the init() signature (unique) — 'init(' alone also matches comments. - const initStart = controllerSource.indexOf( - 'init({ cols, rows, initialData, preserveScroll, oscLinks, frame }' - ) - const initClear = controllerSource.indexOf('writeCoalescer.clear()', initStart) - const initPost = controllerSource.indexOf("type: 'init'", initStart) - expect(initStart).toBeGreaterThanOrEqual(0) - expect(initClear).toBeGreaterThan(initStart) - expect(initClear).toBeLessThan(initPost) - - const clearStart = controllerSource.indexOf('clear() {', initPost) - const clearBody = controllerSource.slice(clearStart, clearStart + 160) - expect(clearStart).toBeGreaterThanOrEqual(0) - expect(clearBody.indexOf('writeCoalescer.clear()')).toBeGreaterThanOrEqual(0) - expect(clearBody.indexOf('writeCoalescer.clear()')).toBeLessThan( - clearBody.indexOf("postMessage({ type: 'clear' })") - ) - }) - - it('flushes pending writes before resize and reflow so boundaries observe prior bytes', () => { - for (const method of ['resize', 'reflow'] as const) { - const start = controllerSource.indexOf( - `${method}(cols: number, rows: number, frame: TerminalFrame | null) {` - ) - expect(start).toBeGreaterThanOrEqual(0) - const body = controllerSource.slice(start, start + 300) - const flushIndex = body.indexOf('writeCoalescer.flushNow()') - const postIndex = body.indexOf(`postMessage({ type: '${method}'`) - expect(flushIndex).toBeGreaterThanOrEqual(0) - expect(postIndex).toBeGreaterThan(flushIndex) - } - }) - - it('clears the coalescer in both document-lifecycle hooks alongside pendingMessages', () => { - // Both hooks now reach one function, so the clearing is asserted once where it lives and the - // two WebView events are asserted to be the callers. Reading only the component would pass on - // a `resetReadiness` that had quietly stopped clearing either one. - const start = controllerSource.indexOf('const resetReadiness = useCallback') - expect(start).toBeGreaterThanOrEqual(0) - const body = controllerSource.slice(start, controllerSource.indexOf('}, [', start)) - expect(body).toContain('pendingMessages.clear()') - expect(body).toContain('writeCoalescer.clear()') - expect(webViewSource).toContain('onLoadStart={resetReadiness}') - const terminated = webViewSource.indexOf('const handleContentProcessDidTerminate') - expect(terminated).toBeGreaterThanOrEqual(0) - expect(webViewSource.slice(terminated, webViewSource.indexOf('}, [', terminated))).toContain( - 'resetReadiness()' - ) - }) - - it('clears the coalescer on unmount so no timer leaks', () => { - const cleanupStart = controllerSource.indexOf('useEffect(() => {\n return () => {') - expect(cleanupStart).toBeGreaterThanOrEqual(0) - const cleanupBody = controllerSource.slice(cleanupStart, cleanupStart + 160) - expect(cleanupBody).toContain('writeCoalescer.clear()') - }) }) diff --git a/mobile/src/test-support/bridged-parity/c2-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c2-page-closure.test.ts deleted file mode 100644 index ca797265cfe..00000000000 --- a/mobile/src/test-support/bridged-parity/c2-page-closure.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { C1_PAGE_CLOSURE } from './c1-page-closure' -import { C2_PAGE_CLOSURE } from './c2-page-closure' -import { C2_TASK_SOURCE_CLOSURE_FAMILIES } from './c2-task-source-closure-families' -import { C2_WORK_ITEM_CLOSURE_FAMILIES } from './c2-work-item-closure-families' -import { C5_PAGE_CLOSURE } from './c5-page-closure' -import { pinsFromSource } from './page-closure-pin-source' -import { BRIDGED_PARITY_EXCLUSIONS } from './divergence-classes' -import { - pageClosureDrift, - pageClosureExclusions, - pageClosureRunTotals, - pageClosureTotals, - type PageClosureObservation -} from './page-closure' -import { readGolden } from '../rpc-recording/golden-recording' - -const GOLDENS = resolve(import.meta.dirname, '../../../rpc-foundation/goldens') -const C1_SOURCE = resolve(import.meta.dirname, 'c1-page-closure.ts') - -/** The run a corpus that diverged exactly as the pin says would hand the rule. */ -function asPinned(): Map { - const run = new Map() - for (const [family, goldens] of Object.entries(C2_PAGE_CLOSURE)) { - for (const [id, verdict] of Object.entries(goldens)) { - run.set(id, { family, verdict }) - } - } - return run -} - -describe('the C2 page closure', () => { - /** - * The census reads the committed table, and does not re-derive the closure. - * - * Nothing here or in the gate runs esbuild or maps a scenario's `sites` back to the module graph, - * so this fixes how many families the table may hold, not which families belong in it. A golden - * arriving in a family already pinned is caught, by `pageClosureDrift` and by this count. A new - * *family* entering the closure — a scenario recorded at a site the route newly imports — is - * invisible until someone re-derives the closure by hand, as C1's and C5's pins are too. - */ - it('is the census the design named: 68 families, 257 goldens', () => { - const goldens = Object.values(C2_PAGE_CLOSURE).flatMap((family) => Object.keys(family)) - expect({ families: Object.keys(C2_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({ - families: 68, - goldens: 257 - }) - expect(new Set(goldens).size).toBe(goldens.length) - }) - - /** - * The totals, asserted beside the per-id pins rather than instead of them. - * - * A walk that compares each id to its own verdict cannot see a table built wrong in a way that is - * self-consistent — C5 derived exactly that file once, with an empty mismatch list and three - * goldens in the wrong class. These counts are what showed it, and the gate re-asserts them - * against the run rather than against the table. - */ - it('pins how many goldens land in each class, which a per-id walk cannot see move', () => { - expect(pageClosureTotals(C2_PAGE_CLOSURE)).toEqual({ - identical: 108, - 'result-absent-settlement': 115, - 'params-undefined': 29, - 'result-absent-stream-release': 3, - 'write-ordinal': 2 - }) - }) - - it("inherits C1's families whole, with the verdicts C1's file commits", () => { - // Against the file's text rather than the imported object, because today the composition - // spreads that object and a comparison to it agrees with itself. This holds the shape of the - // inheritance: that C2's table carries C1's families at C1's verdicts and overrides none of - // them, and it keeps holding if the spread is ever replaced by inlined entries. - // - // What it cannot hold: a verdict changed inside `c1-page-closure.ts`. C2 inherits whatever C1 - // commits, so both sides of this comparison move together. Measured, that edit is red in the - // bridged-parity gate, which compares the pin to a run rather than to a file, and here in the - // class totals and the exclusion count, which pin absolute numbers. It is green in - // `c1-page-closure.test.ts`, whose exclusion check is `toBeGreaterThan(0)` and which pins no - // class totals at all. - // - // C2's own rule does not reproduce these pins — measured, it disagrees on 13 of the 103, being - // `tasks.smart-source-search` 7, `host-worktree-refresh` 5 and `worktree-catalog-snapshot` 1 — - // so inheritance is the derivation rather than a re-derivation that looked close. - const committed = pinsFromSource(C1_SOURCE) - // The reader found a table rather than nothing, which every assertion below depends on. - expect({ - families: Object.keys(committed).length, - pins: Object.values(committed).flatMap(Object.keys).length - }).toEqual({ families: 20, pins: 94 }) - for (const [family, pinned] of Object.entries(committed)) { - expect(C2_PAGE_CLOSURE[family], family).toEqual(pinned) - } - expect(Object.keys(C1_PAGE_CLOSURE)).toEqual(Object.keys(committed)) - }) - - it('agrees with C5 object for object on every family both domains pin', () => { - // Two page closures that share a family share the goldens in it. Were one to be re-derived and - // the other inherited, this is the assertion that would not hold. - const shared = Object.keys(C5_PAGE_CLOSURE).filter((family) => family in C2_PAGE_CLOSURE) - expect(shared.length).toBe(20) - for (const family of shared) { - expect(C2_PAGE_CLOSURE[family], family).toEqual(C5_PAGE_CLOSURE[family]) - } - }) - - it('adds 48 families, split across the two halves without overlap', () => { - // The composed table is a spread, so a family named in both halves would be taken from the last - // one silently. Disjointness is what makes the two files data rather than a precedence rule. - const work = Object.keys(C2_WORK_ITEM_CLOSURE_FAMILIES) - const source = Object.keys(C2_TASK_SOURCE_CLOSURE_FAMILIES) - expect(work.filter((family) => source.includes(family))).toEqual([]) - expect(work.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect(source.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect({ work: work.length, source: source.length }).toEqual({ work: 26, source: 22 }) - const added = Object.keys(C2_PAGE_CLOSURE).filter((family) => !(family in C1_PAGE_CLOSURE)) - expect(added.sort()).toEqual([...work, ...source].sort()) - }) - - it('names the families where a pin proves only that the divergence kept its name', () => { - // "266 certified" would read as 266 proofs of byte-identity. Six families have no byte-identical - // golden at all, so their 27 say only that the class did not change, and the PR body says so. - const wholly = Object.entries(C2_PAGE_CLOSURE) - .filter(([, pins]) => !Object.values(pins).includes('identical')) - .map(([family, pins]) => [family, Object.keys(pins).length] as const) - expect(wholly).toEqual([ - ['host-worktree-refresh', 5], - ['tasks.item-checks-files', 6], - ['tasks.item-metadata-gitlab-mr', 2], - ['tasks.project-row-files-merge', 6], - ['tasks.provider-load', 6], - ['tasks.task-list-gitlab-items', 2] - ]) - }) - - it('pins goldens that exist, in the family the corpus records them under', () => { - for (const [family, goldens] of Object.entries(C2_PAGE_CLOSURE)) { - for (const id of Object.keys(goldens)) { - // Through the corpus's own reader, which checks the format version and the value pool and - // throws a named diagnostic otherwise. - const recorded = readGolden(GOLDENS, id) - expect({ id, family: recorded.family }).toEqual({ id, family }) - } - } - }) - - it('claims no golden the corpus does not have', () => { - const corpus = new Set( - readdirSync(GOLDENS) - .filter((name) => name.endsWith('.json')) - .map((name) => name.replace(/\.json$/, '')) - ) - const missing = Object.values(C2_PAGE_CLOSURE) - .flatMap((family) => Object.keys(family)) - .filter((id) => !corpus.has(id)) - expect(missing).toEqual([]) - }) - - it('excludes a closure golden only into a class that has a reason', () => { - const exclusions = pageClosureExclusions(C2_PAGE_CLOSURE) - expect(exclusions.length).toBe(149) - expect(exclusions.filter(([, name]) => BRIDGED_PARITY_EXCLUSIONS[name] === undefined)).toEqual( - [] - ) - }) -}) - -describe('reading a run against the C2 pin', () => { - it('says nothing when the run is the pin', () => { - expect(pageClosureDrift(C2_PAGE_CLOSURE, asPinned())).toEqual([]) - expect(pageClosureRunTotals(C2_PAGE_CLOSURE, asPinned())).toEqual( - pageClosureTotals(C2_PAGE_CLOSURE) - ) - }) - - it('names a closure golden that changed verdict', () => { - const run = asPinned() - const found = [...run].find(([, seen]) => seen.verdict !== 'params-undefined') - if (found === undefined) { - throw new Error('the pin is empty') - } - const [id, observation] = found - run.set(id, { ...observation, verdict: 'params-undefined' }) - const drift = pageClosureDrift(C2_PAGE_CLOSURE, run) - expect(drift.length).toBe(1) - expect(drift[0]).toContain(id) - expect(drift[0]).toContain(`pinned ${observation.verdict}, ran params-undefined`) - // And in the counts, which is the check that holds when a second golden moves the other way. - expect(pageClosureRunTotals(C2_PAGE_CLOSURE, run)).not.toEqual( - pageClosureTotals(C2_PAGE_CLOSURE) - ) - }) - - it('names a golden newly derived into a closure family, which no id list would', () => { - const run = asPinned() - const [family] = Object.keys(C2_PAGE_CLOSURE) - if (family === undefined) { - throw new Error('the pin is empty') - } - run.set('matrix-arrived-1', { family, verdict: 'identical' }) - expect(pageClosureDrift(C2_PAGE_CLOSURE, run)).toEqual([ - `${family}: arrived matrix-arrived-1; left (none)` - ]) - }) - - it('names a closure golden the run stopped producing, three ways', () => { - const run = asPinned() - const [family, goldens] = Object.entries(C2_PAGE_CLOSURE)[0] ?? [] - const [id] = Object.keys(goldens ?? {}) - if (family === undefined || id === undefined) { - throw new Error('the pin is empty') - } - run.delete(id) - expect(pageClosureDrift(C2_PAGE_CLOSURE, run)).toEqual([ - `${family}: arrived (none); left ${id}` - ]) - // Drift is one of three. The run's totals fall short of the pin's, and the census above fixes - // the number of goldens the table may hold, so a drop cannot be absorbed by deleting the pin. - expect(pageClosureRunTotals(C2_PAGE_CLOSURE, run)).not.toEqual( - pageClosureTotals(C2_PAGE_CLOSURE) - ) - expect([...run].filter(([, seen]) => seen.family in C2_PAGE_CLOSURE).length).toBe(256) - }) - - it('ignores every golden outside the closure, which is most of the corpus', () => { - const run = asPinned() - run.set('session-diff-review-elsewhere', { - family: 'session.diff-review', - verdict: 'result-absent-settlement' - }) - expect(pageClosureDrift(C2_PAGE_CLOSURE, run)).toEqual([]) - }) -}) diff --git a/mobile/src/test-support/bridged-parity/c3-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c3-page-closure.test.ts deleted file mode 100644 index afc1c897564..00000000000 --- a/mobile/src/test-support/bridged-parity/c3-page-closure.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { C1_PAGE_CLOSURE } from './c1-page-closure' -import { C2_PAGE_CLOSURE } from './c2-page-closure' -import { C3_PAGE_CLOSURE } from './c3-page-closure' -import { C3_EXPLORER_CLOSURE_FAMILIES } from './c3-explorer-closure-families' -import { C3_PREVIEW_CLOSURE_FAMILIES } from './c3-preview-closure-families' -import { C5_PAGE_CLOSURE } from './c5-page-closure' -import { pinsFromSource } from './page-closure-pin-source' -import { BRIDGED_PARITY_EXCLUSIONS } from './divergence-classes' -import { - pageClosureDrift, - pageClosureExclusions, - pageClosureRunTotals, - pageClosureTotals, - type PageClosureObservation -} from './page-closure' -import { readGolden } from '../rpc-recording/golden-recording' - -const GOLDENS = resolve(import.meta.dirname, '../../../rpc-foundation/goldens') -const C1_SOURCE = resolve(import.meta.dirname, 'c1-page-closure.ts') - -/** The run a corpus that diverged exactly as the pin says would hand the rule. */ -function asPinned(): Map { - const run = new Map() - for (const [family, goldens] of Object.entries(C3_PAGE_CLOSURE)) { - for (const [id, verdict] of Object.entries(goldens)) { - run.set(id, { family, verdict }) - } - } - return run -} - -describe('the C3 page closure', () => { - /** - * The census reads the committed table, and does not re-derive the closure. - * - * Nothing here or in the gate runs esbuild or maps a scenario's `sites` back to the module graph, - * so this fixes how many families the table may hold, not which families belong in it. A golden - * arriving in a family already pinned is caught, by `pageClosureDrift` and by this count. A new - * *family* entering the closure is invisible until someone re-derives it by hand — which - * `config/scripts/mobile-web-app-page-closure-families.test.mjs` now does for both files routes. - */ - it('is the census the design named: 26 families, 116 goldens', () => { - const goldens = Object.values(C3_PAGE_CLOSURE).flatMap((family) => Object.keys(family)) - expect({ families: Object.keys(C3_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({ - families: 26, - goldens: 116 - }) - expect(new Set(goldens).size).toBe(goldens.length) - }) - - /** - * The totals, asserted beside the per-id pins rather than instead of them. - * - * A walk that compares each id to its own verdict cannot see a table built wrong in a way that is - * self-consistent — C5 derived exactly that file once, with an empty mismatch list and three - * goldens in the wrong class. These counts are what showed it, and the gate re-asserts them - * against the run rather than against the table. - */ - it('pins how many goldens land in each class, which a per-id walk cannot see move', () => { - expect(pageClosureTotals(C3_PAGE_CLOSURE)).toEqual({ - identical: 60, - 'result-absent-settlement': 44, - 'params-undefined': 7, - 'result-absent-stream-release': 3, - 'write-ordinal': 2 - }) - }) - - it("inherits C1's families whole, with the verdicts C1's file commits", () => { - // Against the file's text rather than the imported object: the composition spreads that object, - // so a hand-edited inherited entry would be read back as C1's and agree with itself. - // - // What this cannot see is an edit to `c1-page-closure.ts` itself, because both sides of the - // comparison then move together. What it does see is a C3 half redeclaring an inherited family, - // which the spread would otherwise take silently from the last table. Both measured: flipping - // `settings-repo-metadata-icons` in C1's file leaves this case green and reds seven others — - // the class totals and the exclusion counts in C2's suite and this one, both cross-series - // agreements, and C5's own inheritance case, which compares against an independent literal - // rather than a spread. - // - // Redeclaring `settings.repo-metadata` in the preview half always reds this case; how many - // others go with it depends on the shape of the redeclaration, so the number is not the claim. - // Measured: one golden under the family's name reds seven, because it also shrinks the census - // and leaves that family with no byte-identical golden. The family copied verbatim with a - // single verdict flipped reds five, the census unmoved. Both keep the load-bearing half — the - // spread takes the last table's entry, and this case is what sees it. - // - // C2's rule does not reproduce these pins — measured here, it disagrees on 13 of the 94, - // being `tasks.smart-source-search` 7, `host-worktree-refresh` 5 and - // `worktree-catalog-snapshot` 1 — so inheritance is the derivation rather than a - // re-derivation that looked close. - const committed = pinsFromSource(C1_SOURCE) - expect({ - families: Object.keys(committed).length, - pins: Object.values(committed).flatMap(Object.keys).length - }).toEqual({ families: 20, pins: 94 }) - for (const [family, pinned] of Object.entries(committed)) { - expect(C3_PAGE_CLOSURE[family], family).toEqual(pinned) - } - }) - - it('agrees with C2 and C5 object for object on every family they share', () => { - // Three page closures that share a family share the goldens in it. Were one re-derived and the - // others inherited, this is the assertion that would not hold. - for (const [name, other] of [ - ['C2', C2_PAGE_CLOSURE], - ['C5', C5_PAGE_CLOSURE] - ] as const) { - const shared = Object.keys(other).filter((family) => family in C3_PAGE_CLOSURE) - expect(shared.length, name).toBe(20) - for (const family of shared) { - expect(C3_PAGE_CLOSURE[family], `${name}/${family}`).toEqual(other[family]) - } - } - }) - - it('adds 6 families, split across the two routes without overlap', () => { - // The composed table is a spread, so a family named in both halves would be taken from the last - // one silently. Disjointness is what makes the two files data rather than a precedence rule. - const explorer = Object.keys(C3_EXPLORER_CLOSURE_FAMILIES) - const preview = Object.keys(C3_PREVIEW_CLOSURE_FAMILIES) - expect(explorer.filter((family) => preview.includes(family))).toEqual([]) - expect(explorer.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect(preview.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect({ explorer: explorer.length, preview: preview.length }).toEqual({ - explorer: 1, - preview: 5 - }) - const added = Object.keys(C3_PAGE_CLOSURE).filter((family) => !(family in C1_PAGE_CLOSURE)) - expect(added.sort()).toEqual([...explorer, ...preview].sort()) - }) - - it('names the families where a pin proves only that the divergence kept its name', () => { - // "125 certified" would read as 125 proofs of byte-identity. One family has no byte-identical - // golden at all, so its 5 say only that the class did not change, and the PR body says so. - const wholly = Object.entries(C3_PAGE_CLOSURE) - .filter(([, pins]) => !Object.values(pins).includes('identical')) - .map(([family, pins]) => [family, Object.keys(pins).length] as const) - expect(wholly).toEqual([['host-worktree-refresh', 5]]) - }) - - it('pins goldens that exist, in the family the corpus records them under', () => { - for (const [family, goldens] of Object.entries(C3_PAGE_CLOSURE)) { - for (const id of Object.keys(goldens)) { - const recorded = readGolden(GOLDENS, id) - expect({ id, family: recorded.family }).toEqual({ id, family }) - } - } - }) - - it('claims no golden the corpus does not have', () => { - const corpus = new Set( - readdirSync(GOLDENS) - .filter((name) => name.endsWith('.json')) - .map((name) => name.replace(/\.json$/, '')) - ) - const missing = Object.values(C3_PAGE_CLOSURE) - .flatMap((family) => Object.keys(family)) - .filter((id) => !corpus.has(id)) - expect(missing).toEqual([]) - }) - - it('excludes a closure golden only into a class that has a reason', () => { - const exclusions = pageClosureExclusions(C3_PAGE_CLOSURE) - expect(exclusions.length).toBe(56) - expect(exclusions.filter(([, name]) => BRIDGED_PARITY_EXCLUSIONS[name] === undefined)).toEqual( - [] - ) - }) -}) - -describe('reading a run against the C3 pin', () => { - it('says nothing when the run is the pin', () => { - expect(pageClosureDrift(C3_PAGE_CLOSURE, asPinned())).toEqual([]) - expect(pageClosureRunTotals(C3_PAGE_CLOSURE, asPinned())).toEqual( - pageClosureTotals(C3_PAGE_CLOSURE) - ) - }) - - it('names a closure golden that changed verdict', () => { - const run = asPinned() - const found = [...run].find(([, seen]) => seen.verdict !== 'params-undefined') - if (found === undefined) { - throw new Error('the pin is empty') - } - const [id, observation] = found - run.set(id, { ...observation, verdict: 'params-undefined' }) - const drift = pageClosureDrift(C3_PAGE_CLOSURE, run) - expect(drift.length).toBe(1) - expect(drift[0]).toContain(id) - expect(drift[0]).toContain(`pinned ${observation.verdict}, ran params-undefined`) - }) - - it('names a golden the run stopped producing, and moves the totals with it', () => { - const run = asPinned() - const [family, goldens] = Object.entries(C3_PAGE_CLOSURE)[0] ?? [] - const [id] = Object.keys(goldens ?? {}) - if (family === undefined || id === undefined) { - throw new Error('the pin is empty') - } - run.delete(id) - expect(pageClosureDrift(C3_PAGE_CLOSURE, run)).toEqual([ - `${family}: arrived (none); left ${id}` - ]) - // The totals move too, which is the half a per-id walk over what remains cannot see. - expect(pageClosureRunTotals(C3_PAGE_CLOSURE, run)).not.toEqual( - pageClosureTotals(C3_PAGE_CLOSURE) - ) - }) - - it('ignores every golden outside the closure, which is most of the corpus', () => { - const run = asPinned() - run.set('a-golden-from-another-domain', { - family: 'session.diff-review', - verdict: 'result-absent-settlement' - }) - expect(pageClosureDrift(C3_PAGE_CLOSURE, run)).toEqual([]) - }) -}) diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts index ac5fd17cb70..e262157ac2a 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts @@ -7,7 +7,6 @@ import { classifyBridgedParity, BRIDGED_PARITY_BASELINE, BRIDGED_PARITY_EXCLUSIONS, - BRIDGED_PARITY_FLAG, BRIDGED_PARITY_MEMBERS, BRIDGED_PARITY_OFF, BRIDGED_PARITY_NAMEABLE, @@ -37,12 +36,7 @@ const droppedUndefinedKey: BridgedParityEvidence = { } describe('the bridged-parity flag', () => { - it('is the name the suite and the pin both spell', () => { - expect(BRIDGED_PARITY_FLAG).toBe('RPC_FOUNDATION_BRIDGE') - }) - it('skips on one value only, so an unset or mistyped variable still runs the gate', () => { - expect(BRIDGED_PARITY_OFF).toBe('0') const skips = (value: string | undefined): boolean => value === BRIDGED_PARITY_OFF expect([undefined, '', '1', 'false', 'off'].filter(skips)).toEqual([]) expect(skips('0')).toBe(true) diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.ts b/mobile/src/test-support/bridged-parity/divergence-classes.ts index bd450d52b16..776615c87fe 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.ts @@ -1,10 +1,8 @@ /** * What a bridged-replay divergence is called, decided by a rule rather than by reading a message. * - * Beside the recorder rather than inside it. Reading a failure cannot change what a recording - * records, so `recorderSha256` must not cover this: a tightened rule would otherwise re-record 787 - * headers to say nothing. The recorder's own directory is digested whole, which is why this lives - * one level up in `test-support`. + * Beside the recorder rather than inside it: reading a failure cannot change what a recording + * records, and nothing on the recording path loads this. */ /** diff --git a/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts b/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts index e7cedfff097..0555acf639c 100644 --- a/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts +++ b/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts @@ -15,7 +15,6 @@ import { const scenario = (steps: RecordingScenario['steps']): RecordingScenario => ({ id: 's', operation: 'o', - version: 1, family: 'f', sites: [], schedules: [], diff --git a/mobile/src/test-support/bridged-parity/divergence-evidence.ts b/mobile/src/test-support/bridged-parity/divergence-evidence.ts index 413e90436d8..3a7604a2a8c 100644 --- a/mobile/src/test-support/bridged-parity/divergence-evidence.ts +++ b/mobile/src/test-support/bridged-parity/divergence-evidence.ts @@ -1,6 +1,6 @@ import type { BridgeRpcClientDiagnostic } from '../../mobile-web-shell/bridge/bridge-rpc-client' import { readBridgeHostMessage } from '../../mobile-web-shell/bridge/bridge-envelope' -import { firstDifference } from '../rpc-recording/golden-recording' +import { firstDifference } from '../rpc-recording/golden-difference' import { canonicalJson, OBSERVATION_FIELDS } from '../rpc-recording/golden-value-pool' import type { Observation, Recording, RecordingScenario } from '../rpc-recording/recording-scenario' import { captureValue, type RecordedValue } from '../rpc-recording/recording-values' @@ -10,7 +10,7 @@ import type { ParamsMismatchEvidence } from './divergence-classes' * The facts a divergence is named from, each read off the run rather than off its message. * * Outside the recorder's directory for the reason `divergence-classes.ts` gives: none of this can - * change what a recording records, so none of it belongs in the digest that says what can. + * change what a recording records. */ /** Frames the shell posted that the page's own reader drops. Read back through that same reader. */ diff --git a/mobile/src/test-support/census-source-files.test.ts b/mobile/src/test-support/census-source-files.test.ts index 446a4097f66..c5639bfdf1c 100644 --- a/mobile/src/test-support/census-source-files.test.ts +++ b/mobile/src/test-support/census-source-files.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' @@ -74,36 +74,4 @@ describe('the source files a census reads', () => { expect(isGeneratedSource(entry), entry).toBe(true) } }) - - it('is the only walk of its kind left in the tree', () => { - // The line every census used to hold a copy of. One spelling, so a tenth census cannot quietly - // reintroduce the cost by pasting the walk rather than importing it. - const copies: string[] = [] - const walk = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) { - if (entry.name !== 'node_modules') { - walk(path) - } - continue - } - if (!/\.tsx?$/.test(entry.name) || isGeneratedSource(entry.name)) { - continue - } - if (readFileSync(path, 'utf8').includes("entry.name === 'node_modules' ? []")) { - copies.push(relative(mobileRoot, path)) - } - } - } - walk(join(mobileRoot, 'src')) - walk(join(mobileRoot, 'app')) - // This file is in the list because it carries the line as the text it greps for; the module - // beside it is the walk itself. Named rather than filtered out, so a third entry is a failure - // that reads as one. - expect(copies.sort()).toEqual([ - 'src/test-support/census-source-files.test.ts', - 'src/test-support/census-source-files.ts' - ]) - }) }) diff --git a/mobile/src/test-support/rpc-recording/README.md b/mobile/src/test-support/rpc-recording/README.md index afe33f98bda..d7a261c23c9 100644 --- a/mobile/src/test-support/rpc-recording/README.md +++ b/mobile/src/test-support/rpc-recording/README.md @@ -62,7 +62,7 @@ Reads resolve the declared entry or `null`, and never a write. A write that fed would let a later read return a byte nothing declared, which is the device back inside the recording; writes are recorded as effects instead, where they are observed rather than assumed. That is the whole point of the declaration: every byte a read can return is visible in the scenario -file, and `scenarioSha256` pins it per golden like any other scenario field. +file, and the golden records what the product read from it. ## Scenario actions @@ -134,77 +134,37 @@ visible, it does not make the reduction itself observable. ## Golden schema -Each file records `runnerVersion`, `baseline`, `lockfileSha256` (mobile's lockfile), -`recorderSha256`, `adapterSha256`, `scenarioSha256`, `platform`, `scenarioVersion`, -`projectionVersion`, `goldenFormatVersion`, `operation`, `family`, and `namedDeltas`. `platform` -and `lockfileSha256` are provenance and are not compared: a dependency or OS that changes -behaviour changes the trace itself, so comparing them would only fail candidates on unrelated -bumps. The rest are pinned. +Each file holds `goldenFormatVersion`, `operation`, `family`, `namedDeltas`, the value pool and the +interned recording. Nothing else: no commit it was recorded from and no digest of its inputs. Every +run of the recording suites re-derives each golden from the current tree and compares it, which is +a stronger claim than "reproduces from some pinned commit", and git already records where each byte +came from. -`recorderSha256` covers every non-markdown file under this directory **except `adapters/`**, so the -engine that produced a golden is as pinned as the product baseline: editing the runner, the -transport, the projection or a fixture fails candidate mode on the header of every golden and -forces a deliberate re-record of all of them. +Format version 5 and earlier also carried a pinned `baseline` commit and digests of the recorder, +the mount adapter and the scenario. A digest could only fail when an input changed and the recording +did not, which is exactly the change that carries no information, and the pin had to name the +recording branch's own commit, which a squash merge then made unreachable. Every behaviour change +rewrote all 787 headers and needed a follow-up pull request to repin main; version 6 dropped all of +it. `rpc:diff` still reads the old files, so a diff across the change is a real diff. -`adapterSha256` covers the source of the mount adapter module _that golden_ was recorded through — -the file under `adapters/` that mounts each operation its scenarios drive, read off the same -`mounts` calls that build the table the recording runs against, so the pin cannot name a file the -runner did not use. Adding a domain's module re-digests nothing that was already recorded, and -editing one fails exactly the goldens mounted through it. The adapters used to sit in -`recorderSha256` with the engine, which made every golden's header a function of every other -family's adapter: #20568 added two task modules and put a conflict on that one line in 153 files, -against every domain branch in flight. +A failed replay reports the identity fields that moved by name, the checkpoint list, then each +differing (checkpoint, field) at its first differing JSON path with both resolved values, grouped +where an append-only field re-states the same moved entry at later checkpoints; past eight groups it +counts the rest. The failure ends with the command that re-records that golden. -`mutants/` is excluded for a different reason: nothing there is pinned by anything. A file that -cannot change a recording is not provenance for one, and pinning it would claim a provenance the -golden does not have — while charging every domain that adds a mutant a re-record of all 153 -files. -The mutant table, the per-family mutant registry, the reference states and the suites that apply -them all live there. What makes the exclusion sound is that no recording can reach them: the loader -takes a resolved mutation spec instead of importing a table by name. -`mutants/mutant-seam.test.ts` is the check, and it proves reachability forward, walking the static -import graph from the two recording drivers and failing if any module under `mutants/` appears in -it. Naming the directory is rejected too, in either spelling, for the paths a module can be read by -rather than imported; `MUTANT_DIRECTORY` is not exported for the same reason. A path assembled at -runtime from fragments would defeat both, which is the seam's remaining edge. - -For the same reason `recorderSha256` pins only the suites in `recording-drivers.ts`. A golden's -bytes come from `pilot-recordings.test.ts` or `family-recordings.test.ts` and from what they -import; a suite that reads goldens, or writes one to a scratch directory, puts no observation in a -recorded file. `scripts/rpc-recording.mts` records exactly that list, so the two cannot drift apart. +`mutants/` is unreachable from the recording drivers by rule, not by convention: +`mutants/mutant-seam.test.ts` walks the static import graph from the two drivers and fails if any +module under `mutants/` appears in it, and refuses a recording file that names the directory. A +mutant planted on the recording path would be recorded and replayed alike, so every golden would +compare clean while certifying the mutated code. A module-private product export an adapter drives is exposed by its own module — see -`settingsMountExposures` — not by a shared table, because the exposure text does change what a -recording loads. Each domain module gets its own loader carrying its own exposures, and one -recording mounts one adapter, so `adapterSha256` pins exactly the exposures that reached it. - -One exposure is shared instead, and pays for it: five domains mount a screen that reads the client -off the context `client-context.tsx` keeps module-private, and each used to carry its own copy of -`exports.recorderHostClientContext = Ctx;`. That string names a local no type checker follows, so -five spellings were five independent ways to reach a `ReferenceError` seconds into a recording. -`hostClientContextExposure` is the one copy; the trade is that it sits inside `recorderSha256`, so -editing it re-records all 787 goldens rather than the five families. A rename of the local is still -invisible to `tsc` — nothing short of editing the product module makes a private local checkable — -so `adapter-seam.test.ts` asserts the declaration it names exists exactly once, and refuses a sixth -inline copy. - -The adapter seam is the directory, not a filename convention, because a convention is a rule nobody -enforces. `adapter-seam.test.ts` enforces this one: every file under `adapters/` is a registered -module, every registered module is declared in the file it is registered under, no adapter module -imports a sibling (which would leave a golden pinned to one module and driven by two), and -`pilotMountAdapters` mounts nothing of its own — an adapter defined in an engine file would be -pinned by `recorderSha256` on all 153 goldens instead of by `adapterSha256` on its own. - -`scenarioSha256` covers the scenario input _that golden_ was recorded from — one manifest scenario -for a pilot golden, the generated variants and any hoisted prelude for a matrix or schedule golden, -canonicalised by `captureValue` so an explicit-undefined param stays distinct from an absent one. -Editing a scenario still fails candidate mode on the header, but only for the goldens derived from -it. The manifest used to be an input to `recorderSha256` instead, which made every golden's header -a function of every other family's scenarios: adding one domain's family re-digested all 153 files -and put a conflict on that line in every domain branch in flight. Which goldens a manifest derives -lives in `derived-goldens.ts`, so the digest is a function of the same derivation that records the -file rather than of a restatement of it; `golden-header-digest.test.ts` pins what both separations -buy. +`settingsMountExposures` — not by a shared table. One exposure is shared instead: five domains +mount a screen that reads the client off the context `client-context.tsx` keeps module-private, and +`hostClientContextExposure` is the one copy of that string. A rename of the local is invisible to +`tsc`, so `adapter-seam.test.ts` asserts the declaration it names exists exactly once, and refuses a +sixth inline copy. The same test requires every file under `adapters/` to be registered in +`adapters/mounted-operation-modules.ts`. Checkpoints contain ordered sender calls and serialized physical application payloads, action and request settlements, projected state, and ordered external effects. Each sender call, each payload @@ -234,12 +194,7 @@ positional slots; absent, undefined and null are distinct `$rpc` tags. Literal o `$rpc` are escaped. Only object keys are sorted; array/effect order, options, budgets, settlement times and errors stay observable. Errors contain category, message and `isRpcDeliveryUnknown`, never stack paths, plus `code` and a recursively captured `cause` when the thrown error carries them. -Platform is provenance; candidate comparison does not require the same operating system. - -Format version 4 added `scenarioSha256` and version 5 adds `adapterSha256`. A stale golden would -already fail this reader's byte compare, so each bump buys the diagnosis rather than the rejection: -`readGolden` names the stale format and says to re-record, instead of reporting an opaque -`(encoding)` difference. Neither bump moved an observation. +A golden records no operating system, so comparison does not require the one it was recorded on. ### Value pool @@ -263,11 +218,15 @@ early request every downstream checkpoint re-states — touches the same 16 file under version 2 that is ±17,100 lines and 1.03 MB of diff, and under version 3 ±3,764 lines and 0.20 MB, because a moved entry no longer rewrites every field value that contains it. -`readGolden` refuses any other `goldenFormatVersion`, checks that every pooled entry hashes to its -own key and that no entry sits in the pool unreferenced — content addressing is what keeps an entry -shared across checkpoints honest, and an unread entry would be content in the file that nothing -compares. It then resolves hashes back to values, and `compareGolden` reports the scenario, the -checkpoint id, the field, the JSON path inside it, and both resolved values. +Replay passes only if the committed file is exactly the text `rpc:record` would write for the run, +so nothing the file carries goes uncompared: a leftover header key, a stale pool entry, reordered +keys or a hand edit all fail with the re-record command. When the text differs, `readGolden` decodes +the file for the report: it refuses any other `goldenFormatVersion`, checks that every pooled entry +hashes to its own key and that no entry sits in the pool unreferenced (a file it cannot decode fails +with the re-record command too), and resolves hashes back to values; the report names the scenario, +the checkpoint id, the field, the JSON path inside it, and both resolved values, or says every field +matches and only the file text differs. `rpc:diff` decodes leniently instead, so it still reads the +old formats. ### Prelude checkpoints @@ -280,9 +239,9 @@ the same state through different inputs is evidence. Sibling schedules already d prefix, so they are unchanged. Nothing about a shared prelude is unverified. The `.prelude` scenario's checkpoints live in the same -golden as the variants that start after them, and `compareGolden` walks every checkpoint in the -file, so changing the prelude fails the golden it belongs to. The value pool does not weaken that: -it is per-file and content-addressed, so a prelude entry a later checkpoint re-states is stored once +golden as the variants that start after them, and replay compares every checkpoint in the file, +so changing the prelude fails the golden it belongs to. The value pool does not weaken that: it is +per-file and content-addressed, so a prelude entry a later checkpoint re-states is stored once and any change to it moves the hash in every checkpoint that reads it. Family matrices and schedule recordings retain both boundaries. Matrices execute @@ -307,7 +266,7 @@ without a message, `method_not_found`, and a transport rejection with and withou that were recorded before and are gone were unreachable: `successResponse` always sets `result`, so JSON carries no explicit-undefined slot, and no mounted method's handler returns a number, a string, an array, a bare `{}`, or a boolean. `null` stays because `linear.getIssue` returns it for a missing -issue and the b2 seed is a shipped null-result bug. +issue and the b2 seed is a null result accepted as success. The message-less refusal and rejection are what separate the two failure paths a migrated call site must keep apart: a refusal with no message falls back to the screen's copy, a transport drop with no @@ -336,7 +295,7 @@ success fails the suite until it is given a fulfilled scenario or a line in `REPLY_MATRIX_NORMAL_RESULT_INVENTORY`, which carries the reply and the reason; an entry whose family has since recorded a success fails too, so the list only shrinks. Four sites are on it: both legs of the b3 seed, whose single scenario exists to record the defect; the b2 seed, whose only -recorded success is the shipped null result; and `settings.update`, a best-effort write whose reply +recorded success is the null result; and `settings.update`, a best-effort write whose reply body no call site reads. Detached unhandled rejections are captured as effects in a sequential process-scoped window, @@ -344,21 +303,29 @@ with prior process listeners restored afterward. The known main bug it first rec both legs: `new-workspace-runtime-context-null-results-degrade-to-absent` now records a null or absent `settings.get` or `ui.get` result degrading the way a reply missing that member does, so neither matrix golden carries a property-read TypeError effect any more. That leaves no golden -recording an unhandled rejection at all, so `unhandled-recording.test.ts` is what pins the capture: +recording an unhandled rejection at all, so `unhandled-recording.test.ts` is what pins the capture, +both the helper and its wiring through `runRecording` into a checkpoint and the cleanup checkpoint: without it a refactor could stop emitting the effect and every golden would still compare clean. Task-model projections record setter invocations and resulting model values, not native UI. -## Commands and checker contract - -Record only from unchanged pinned product sources and lockfile. The fence exempts only -`mobile/src/test-support/rpc-recording`, which `recorderSha256` and `adapterSha256` pin between -them; every other test-support path is compared against the baseline like product code: +## Commands ```sh -ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record -ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test src/test-support/rpc-recording +ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test src/test-support/rpc-recording # replay and compare +pnpm --dir mobile rpc:record # re-record every golden from the current tree +pnpm --dir mobile rpc:record ... # re-record only these (ids as the failure prints) +pnpm --dir mobile rpc:record --prune # also delete goldens the manifest no longer derives +pnpm --dir mobile rpc:diff [] # decode what moved against (default: merge base with origin/main) ``` +With no base, `rpc:diff` includes the moves the branch already committed; `rpc:diff HEAD` shows +only the uncommitted ones. + +Recording is deterministic and carries no header, so an unchanged behaviour re-records to the same +bytes on any machine: recording everything is always safe, and `git diff` shows only what moved. +Without `--prune`, a golden the manifest stopped deriving is listed and the census test +(`derived-goldens.test.ts`) keeps failing on it; deleting coverage is always explicit. + Mutants are the defect evidence. `mutants/operation-mutations.ts` holds one anchored source edit per adapter family, and every family's recording must change visible state when its mutant is applied, which is what shows that family's `state()` projection observes the operation's real @@ -368,7 +335,8 @@ hook. `runRecordingMutant` accepts a mutated mounting adapter, scheduler, baseli observation projection, and returns `{verdict: "killed" | "survived", recording}`. Every mutant test requires the mutation to apply exactly once and change visible state to count as killed. -Set `RPC_FOUNDATION_REFERENCE_ROOT` to an archived `bcba08b3e4` source tree to corroborate the +The b1, b2 and b3 seeds are regressions from an unmerged refactor (`bcba08b3e4`, never on main), +not shipped bugs. Set `RPC_FOUNDATION_REFERENCE_ROOT` to an archived `bcba08b3e4` source tree to corroborate the three B-seed mutants against the real defect; the reference checkout is never edited. Each seed pins the archived tree's visible state, so a later refactor of those files cannot pass by merely differing from main. Archived-tree corroboration for b1/b2/b3 is **unproven in CI**: @@ -378,14 +346,30 @@ families because no reference states are defined for them. ## What this oracle does and does not see -It replays 397 manifest scenarios against frozen goldens and fails on any divergence: 787 goldens -over 790 tests, all inside `pnpm --dir mobile test`. Counted with -`python3 -c "import json;print(len(json.load(open('mobile/rpc-foundation/pilot-scenarios.json'))['scenarios']))"`, -`find mobile/rpc-foundation/goldens -type f | wc -l`, and the reported total of -`vitest run src/test-support/rpc-recording/{pilot,family}-recordings.test.ts src/test-support/rpc-recording/derived-goldens.test.ts`. Counts quoted further down are measurements of -the change they describe and are not restatements of this one. For a migration it answers one -question — does the rewritten call site produce the same sender calls, settlements, state and -effects as main did? +It replays every manifest scenario against its committed golden and fails on any divergence, all +inside `pnpm --dir mobile test`. Counted with +`python3 -c "import json;print(len(json.load(open('mobile/rpc-foundation/pilot-scenarios.json'))['scenarios']))"` +and `find mobile/rpc-foundation/goldens -type f | wc -l`. Counts quoted further down are +measurements of the change they describe and are not restatements of this one. For a refactor it +answers one question — does the rewritten call site produce the same sender calls, settlements, +state and effects as the base branch did? A pull request that moves no golden says yes. + +### Recorded requests against the host's params contract + +The goldens script the host's replies, so nothing in a replay stops a scenario from recording a +success for a request the real host would refuse. `recorded-request-params.test.ts` parses every +distinct request the corpus puts on the wire with the host dispatcher's own +`parseRpcRequestParams` and the schema `rpc-params-catalog.generated.ts` binds to that method. It +fails on a method the host does not have, params the host refuses, params sent to a method that +takes none (the dispatcher never reads them), and keys the schema silently strips, unless an entry +in its inventory gives the reason; an entry nothing strips fails too. It runs in the Mobile Checks +suite, which a desktop pull request to `src/shared/` triggers, so tightening a host schema that +today's phone requests do not satisfy fails there. It checks against the current +host only, and it does not check the scripted replies against real host results. + +When it landed it found twelve requests the host would refuse, all from invented fixture values +rather than product code: git object ids that were not ids, an iOS push token without its APNs +environment, a Linear filter passed as a GitHub preset, and a GitLab project reference as a string. It is not a substitute for reading the diff. Five facts bound it, all learned the hard way: @@ -477,88 +461,38 @@ the runtime task settings. That divergence is recorded, not repaired — `settings-task-hydration-refuse-after-data.json` is the observation, and changing the behaviour is a product change with its own re-record. -### Running it for a step-4 migration +### Working with the recordings -```sh -# 1. Before touching the call site, confirm the oracle is green on your branch. -ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test src/test-support/rpc-recording +- **Refactor.** Run the suite. No golden moves: that is the parity proof. If one moves, the failure + names the golden, checkpoint, field and path; `rpc:diff` shows the same after a re-record. +- **Behaviour change.** Change the product, run the suite, read each failure, then + `pnpm --dir mobile rpc:record` and commit. The pull request diff is exactly the goldens whose + recorded behaviour moved; say why in the pull request. Nothing is owed after the merge. +- **New scenario.** Add it to `pilot-scenarios.json`. The suite fails naming each missing golden + and the command that records it; run that command and commit the new files. +- **Recorder change.** Re-record. The diff is exactly what the recorder now observes differently; + if there is none, there is nothing to commit. +- **Review.** Read the "RPC recording changes" section of the Mobile Checks job summary, which is + `rpc:diff` against the base. Each changed file is one behaviour move. +- **Merge conflict in a golden.** Never hand-merge one: goldens are derived. Take the incoming side + (`git checkout origin/main -- mobile/rpc-foundation/goldens`, from the incoming ref, not `HEAD`), + finish merging the product code and the scenarios, run `rpc:record`, then + `rpc:diff origin/main` and check it lists only the moves your branch means to make — a re-record + would otherwise absorb a regression the merge introduced. +- **Red main.** `Mobile tests on main` replays the whole mobile suite on every merge, because two + pull requests can each pass against their own base and disagree once both land. The failure names + each golden and the command; whoever merged last re-records in a follow-up. -# 2. Migrate the call site. Re-run. Any divergence is your diff, reported down to the JSON path. +A desktop pull request can move a golden too: the recordings import a few hundred modules under +`src/shared`. Mobile Checks therefore runs on any pull request that touches `src/shared/**` or the +root lockfile, not only on changes under `mobile/`. -# 3. If a divergence is intended, say so deliberately. Recording refuses to run unless the -# product tree matches the pinned baseline, so bump `baseline` in pilot-scenarios.json to the -# commit you are recording from first. -ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 \ - pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record -``` - -A call site that subscribes runs the same recipe, with one thing to check before step 2. The -subscribe payload is named `#` by per-method occurrence, and every `frame` step in the -scenario names it — so a migration that moves the subscribe past another send of the same method -renames it and the scenario no longer resolves. That is a loud failure, not a silent one +A subscription scenario names its payload `#` by per-method occurrence, and every +`frame` step names it too — so a change that moves the subscribe past another send of the same +method renames it and the scenario no longer resolves. That is a loud failure, not a silent one (`Missing subscription payload`), but it is the first thing to read when a subscription scenario stops matching. -A re-record is a claim about behaviour. State the cause in the commit; every golden the refresh -moves should have one. - -### Recording a behaviour change - -A change that moves what a screen observes cannot be recorded from main's pin: the fence compares -the working tree against `baseline`, so the migrated source has to be what `baseline` names. Do not -record into a scratch directory and copy the moved files back — that leaves those goldens pinned to -a tree that does not produce them, which is the one claim this header exists to make. - -1. Land the product change first, so every fenced path (`mobile/src`, `src/shared`, - `mobile/pnpm-lock.yaml`) is final and committed. -2. Repin `baseline` in `pilot-scenarios.json` to that commit on your own branch. Nothing else: not - main, not a tree you have not committed. The manifest is outside the fenced paths, so the repin - may sit uncommitted while you record. After merging main the pin is the merge commit, since that - is the last commit to touch a fenced path and the only tree the fence can match. -3. Re-record everything, not a subset, with the `--record` command above. The repin rewrites the - `baseline` header of every golden, so every file moves and a partial refresh would leave the - corpus pinned to two different trees. -4. Prove the delta by decoding the value pool of every golden against the branch point and sorting - the files into four classes: header-only, body moved, added, deleted. The disclosed behaviour - change is exactly the body-moved set; anything else in the last three classes is an unintended - move to explain before committing. Know which header keys your own branch moves before you - read the header-only class, or you will not recognise a clean result: the repin moves - `baseline` on every golden, a branch that edited anything under `RECORDER_DIRECTORY` also moves - `recorderSha256` on every golden, and a branch that edited one adapter module moves that - family's `adapterSha256`. Any key outside that set is the finding. `scenarioSha256` hashes the - derived scenarios rather than the manifest, so a repin alone never moves it. -5. Commit the repin and the refresh together, and state the cause. - -A squash-merge leaves the pinned sha out of main's history, and no follow-up repin is needed: the -pin guard asks GitHub which pull requests contain the pinned commit -(`GET /repos/{owner}/{repo}/commits/{sha}/pulls`), fetches each one's `refs/pull//head`, which -GitHub keeps after the branch is deleted, and passes only when git finds the pin in one of those -heads. The squash title plays no part, so editing it at merge time is safe. Run `pnpm --dir mobile -exec tsx scripts/rpc-recording-pin-guard.mts reachable` to fetch a pin your clone does not have -before checking it out. It works without a token, and sends `GITHUB_TOKEN` or `GH_TOKEN` when one -is set, which lifts GitHub's unauthenticated rate limit. A reviewer checking an in-flight branch -resolves the pin against the branch, where it is a real commit. - -Editing the recorder engine on a migration branch is the awkward case: `recorderSha256` moves, so -every golden needs rewriting, but the product tree no longer matches `baseline`, and bumping -`baseline` to the branch would record the migrated source and make the parity claim circular. Record -from the pinned commit instead, with this branch's recorder laid over it: `git worktree add ---detach `, this tree's `rpc-recording/` and `pilot-scenarios.json` copied in, -`node_modules` symlinked, `RPC_FOUNDATION_GOLDENS` pointed at a scratch directory — then copy the -result back and run the candidate suite here. It must be a worktree, not a `git archive` -extraction: the fence runs `git diff --quiet ` and an untracked-file check, both of which -need a real `.git`, so an archive tree fails as `Product sources or lockfile differ from the pinned -main baseline` — a product mismatch that is not there. Format the recorder before recording: an -`oxfmt` pass afterwards moves `recorderSha256` again. A recorder-only branch that has merged main -is not the awkward case: its product tree is main's, so repin `baseline` to main's tip and record -in place — there is no migrated source for the goldens to be recorded against. That repin is the -whole of it, though. Where a branch is told not to repin, `--record` refuses on any product tree -that is not the pinned one, merged or not, and the detached-pin worktree above is the only recipe -that runs. Adding or editing -one domain's module under `adapters/` no longer needs any of this: only that domain's goldens move, -and they re-record from its own branch like any other behaviour change. Adding a mutant, a probe or -a suite that does not record needs none of it either, and moves no golden at all. - If your call site carries a mutation anchor in `mutants/operation-mutations.ts`, rewriting it will make the anchor match zero sites. Re-anchor the same defect at its new home rather than deleting the mutant: #20499 broke five anchors that way, and each one had a new home. @@ -619,7 +553,7 @@ the drop happened under, and records a non-empty report as a `reply-salvage` eff operation, the method, the decoded variant, the dropped paths and the count. Nothing in the product tree changes: the report was already being built and thrown away. -44 of the 787 goldens carry one, and every other checked read in the corpus decodes its reply +44 of the goldens carried one when this was written, and every other checked read in the corpus decodes its reply whole (`grep -l reply-salvage mobile/rpc-foundation/goldens/*.json | wc -l`). The matrix varies the envelope a host sends rather than the shape of a row inside a result, so on most families this observation pins an absence rather than a recorded drop. What it buys is the next tightening: an element or member schema narrowed so a recorded row stops parsing moves the diff --git a/mobile/src/test-support/rpc-recording/adapter-digest.ts b/mobile/src/test-support/rpc-recording/adapter-digest.ts deleted file mode 100644 index 81966d4b35c..00000000000 --- a/mobile/src/test-support/rpc-recording/adapter-digest.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { join, posix } from 'node:path' -import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' -import { operationModuleLoader } from './operation-module-loader' -import { ADAPTER_DIRECTORY } from './recorder-digest' -import type { MountedOperationModule } from './mounted-operation-module' -import type { RecordingScenario } from './recording-scenario' - -const digests = new Map() - -/** - * Which module mounts each operation, read off the same `mounts` calls that build the table a - * recording runs against. A restated map could agree with itself while naming the wrong source. - */ -export function adapterSourceByOperation( - root: string, - registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES -): Map { - const modules = operationModuleLoader(root) - const owners = new Map() - for (const module of registered) { - for (const operation of Object.keys(module.mounts(modules, {}))) { - owners.set(operation, module.source) - } - } - return owners -} - -/** - * The adapter source one golden was recorded through: the module mounting each operation its - * scenarios drive, deduplicated and ordered by file name. - * - * Pinned per golden rather than over the whole `adapters/` directory so a domain that adds its - * module moves nothing that was already recorded, and editing a module fails exactly the goldens - * that mounted it. A golden whose operation no module mounts has no runner to be attributed to, - * so it throws rather than digesting an empty set. - */ -export function adapterSha256( - root: string, - scenarios: readonly RecordingScenario[], - registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES -): string { - const owners = adapterSourceByOperation(root, registered) - const sources = [ - ...new Set( - scenarios.map((scenario) => { - const source = owners.get(scenario.operation) - if (source === undefined) { - throw new Error(`No adapter module mounts ${scenario.operation}`) - } - return source - }) - ) - ].sort() - const key = `${root}\0${sources.join('\0')}` - const cached = digests.get(key) - if (cached !== undefined) { - return cached - } - const digest = createHash('sha256') - .update( - sources - .map( - (source) => - `${source}:${readFileSync(join(root, ...ADAPTER_DIRECTORY.split(posix.sep), source))}` - ) - .join('\n') - ) - .digest('hex') - digests.set(key, digest) - return digest -} diff --git a/mobile/src/test-support/rpc-recording/adapter-load-deferral.test.ts b/mobile/src/test-support/rpc-recording/adapter-load-deferral.test.ts index 9d18ed02f65..a6d1179900f 100644 --- a/mobile/src/test-support/rpc-recording/adapter-load-deferral.test.ts +++ b/mobile/src/test-support/rpc-recording/adapter-load-deferral.test.ts @@ -6,9 +6,8 @@ import type { operationModuleLoader } from './operation-module-loader' * Building a module's table must name product sources without reading them. A `modules.load` hoisted * out of `useHook` and into the table literal loads at registration time instead of at mount time, * which breaks two things far from the edit: a mutant anchored in a file two families share is then - * applied more than once and `assertMutationApplied` reports the wrong count, and - * `golden-header-digest.test.ts` builds its tables in a tree holding one family's files and throws - * `Module not found` for the rest. Both read as an engine fault; neither names the adapter. + * applied more than once and `assertMutationApplied` reports the wrong count. That reads as an + * engine fault and does not name the adapter. */ function refusingLoader(): ReturnType { return { diff --git a/mobile/src/test-support/rpc-recording/adapter-seam.test.ts b/mobile/src/test-support/rpc-recording/adapter-seam.test.ts index 199d3c6f33a..491aae9da21 100644 --- a/mobile/src/test-support/rpc-recording/adapter-seam.test.ts +++ b/mobile/src/test-support/rpc-recording/adapter-seam.test.ts @@ -1,179 +1,26 @@ import { readFileSync, readdirSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import ts from 'typescript' +import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { adapterSourceByOperation } from './adapter-digest' import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' -import { operationModuleLoader } from './operation-module-loader' -import { pilotMountAdapters } from './pilot-mount-adapters' -import { ADAPTER_DIRECTORY, RECORDER_DIRECTORY } from './recorder-digest' import { HOST_CLIENT_CONTEXT_LOCAL, hostClientContextExposure } from './host-client-context-exposure' -import { readScenarios } from './scenario-input' const root = resolve(import.meta.dirname, '../../../..') -const manifest = readScenarios( - process.env.RPC_FOUNDATION_SCENARIOS ?? - resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json') -).scenarios -const engine = join(root, RECORDER_DIRECTORY) -const directory = join(root, ADAPTER_DIRECTORY) -/** The register is the seam's own index, not an adapter: no golden is recorded through it. */ +const engine = resolve(import.meta.dirname) +const directory = join(engine, 'adapters') +/** The register is the seam's own index, not an adapter. */ const REGISTER = 'mounted-operation-modules.ts' -const registerPath = join(directory, REGISTER).replace(/\.ts$/, '') const sources = MOUNTED_OPERATION_MODULES.map((module) => module.source) -/** Relative specifiers, resolved against the importing file's directory, extension dropped. */ -function imports(from: string, contents: string): { specifier: string; target: string }[] { - return [...contents.matchAll(/(?:from|import\()\s*'(\.[^']*)'/g)].map((match) => ({ - specifier: match[1]!, - target: resolve(from, match[1]!).replace(/\.tsx?$/, '') - })) -} - -function read(source: string): string { - return readFileSync(join(directory, source), 'utf8') -} - -/** Every named import the register makes, local name to specifier. */ -function registerImports(file: ts.SourceFile): Map { - const bindings = new Map() - for (const statement of file.statements) { - if (!ts.isImportDeclaration(statement)) { - continue - } - const clause = statement.importClause - if (!clause || clause.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier)) { - continue - } - const named = clause.namedBindings - if (!named || !ts.isNamedImports(named)) { - continue - } - for (const element of named.elements) { - if (!element.isTypeOnly) { - bindings.set(element.name.text, statement.moduleSpecifier.text) - } - } - } - return bindings -} - -/** The registered entries as written, so a literal in the register is visible as a literal. */ -function registerEntries(file: ts.SourceFile): ts.ObjectLiteralExpression[] { - for (const statement of file.statements) { - if (!ts.isVariableStatement(statement)) { - continue - } - for (const declaration of statement.declarationList.declarations) { - const initializer = declaration.initializer - if ( - declaration.name.getText() === 'MOUNTED_OPERATION_MODULES' && - initializer && - ts.isArrayLiteralExpression(initializer) - ) { - return initializer.elements.filter((element) => ts.isObjectLiteralExpression(element)) - } - } - } - throw new Error('MOUNTED_OPERATION_MODULES is not an array literal in the register') -} - -function property(entry: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined { - for (const member of entry.properties) { - if (ts.isPropertyAssignment(member) && member.name.getText() === name) { - return member.initializer - } - } - return undefined -} - -/** - * `recorderSha256` covers the engine and `adapterSha256` covers one module per golden, so a file on - * the wrong side of this directory is pinned by the wrong thing — an engine file here escapes every - * golden, and an adapter outside re-digests all of them. Both fail here on the move instead. - */ -describe('the engine/adapter seam', () => { +describe('the adapter directory', () => { + // A module left out of the register mounts nothing, so its scenarios fail as unknown operations. it('registers every file in the adapter directory', () => { const present = readdirSync(directory).filter((file) => file !== REGISTER) expect(present.sort()).toEqual([...sources].sort()) }) - it('pairs each registered module with the file that declares it', () => { - expect(new Set(sources).size).toBe(sources.length) - const unpaired = MOUNTED_OPERATION_MODULES.filter( - ({ source, mounts }) => !read(source).includes(`export function ${mounts.name}(`) - ).map(({ source, mounts }) => `${mounts.name} is not declared in ${source}`) - expect(unpaired).toEqual([]) - }) - - // An adapter reaching sideways would leave a golden pinned to one module and driven by two. - // Resolved, not spelled: `../adapters/other` climbs out and back in, and reads as an escape. - it('leaves the seam for every import an adapter module makes', () => { - const inward = sources.flatMap((source) => - imports(directory, read(source)) - .filter(({ target }) => target.startsWith(`${directory}${sep}`)) - .map(({ specifier }) => `${source} imports ${specifier}`) - ) - expect(inward).toEqual([]) - }) - - /** - * The same seam from the other side. `recorderSha256` skips this directory and `adapterSha256` - * names one module per golden, so an engine file that imports an adapter executes code that - * every golden recorded through a different domain leaves out of its header. Only the register - * may be crossed to, because it is the one file here that carries nothing of its own. - */ - it('reaches the adapter directory only through the register, from every engine file', () => { - const crossings = readdirSync(engine, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) - .flatMap((entry) => - imports(engine, readFileSync(join(engine, entry.name), 'utf8')) - .filter( - ({ target }) => target.startsWith(`${directory}${sep}`) && target !== registerPath - ) - .map(({ specifier }) => `${entry.name} imports ${specifier}`) - ) - expect(crossings).toEqual([]) - }) - - /** - * The register is pinned by nothing: the engine digest skips this directory and `adapterSha256` - * reads each entry's `source`, never the register. That is only sound while the register is an - * index — a `mounts` or `exposes` written inline here would drive a recording that no digest - * covers. Both must be identifiers imported from the entry's own module, so whatever they carry - * lives in the file that golden already pins. - */ - it("carries no behaviour of its own, only bindings from each entry's module", () => { - const file = ts.createSourceFile(REGISTER, read(REGISTER), ts.ScriptTarget.Latest, true) - const bindings = registerImports(file) - const entries = registerEntries(file) - expect(entries.length).toBe(MOUNTED_OPERATION_MODULES.length) - const carried = entries.flatMap((entry) => { - const source = property(entry, 'source') - if (!source || !ts.isStringLiteral(source)) { - return ['an entry declares no literal source'] - } - const expected = `./${source.text.replace(/\.ts$/, '')}` - return ['mounts', 'exposes'].flatMap((field) => { - const value = property(entry, field) - if (!value) { - return field === 'mounts' ? [`${source.text} registers no mounts`] : [] - } - if (!ts.isIdentifier(value)) { - return [`${source.text} writes ${field} inline instead of importing it`] - } - const from = bindings.get(value.text) - return from === expected - ? [] - : [`${source.text} takes ${field} from ${from ?? 'no import'}`] - }) - }) - expect(carried).toEqual([]) - }) - it('keeps the host-client context exposure in one place, still anchored on the product source', () => { // The exposure reaches for a module-private local by name, which no type checker follows: a // rename lands as a `ReferenceError` several seconds into a recording. One copy, asserted @@ -182,8 +29,6 @@ describe('the engine/adapter seam', () => { const declaration = `const ${HOST_CLIENT_CONTEXT_LOCAL} = createContext` const context = readFileSync(join(root, 'mobile/src/transport/client-context.tsx'), 'utf8') expect(context.split(declaration).length - 1).toBe(1) - // The engine directory too: a copy there is pinned by `recorderSha256` rather than - // `adapterSha256`, but it is the same unchecked spelling of the same module-private local. // Sources only, since the README quotes the string to document it. const copies = [engine, directory] .flatMap((from) => @@ -196,20 +41,4 @@ describe('the engine/adapter seam', () => { .sort() expect(copies).toEqual([]) }) - - it('mounts nothing outside a registered module', () => { - const modules = operationModuleLoader(root) - const registered = MOUNTED_OPERATION_MODULES.flatMap((module) => - Object.keys(module.mounts(modules, {})) - ) - expect(Object.keys(pilotMountAdapters(root).adapters).sort()).toEqual([...registered].sort()) - }) - - it('attributes every recorded operation to a registered module', () => { - const owners = adapterSourceByOperation(root) - const orphans = [...new Set(manifest.map((scenario) => scenario.operation))] - .filter((operation) => !owners.has(operation)) - .sort() - expect(orphans).toEqual([]) - }) }) diff --git a/mobile/src/test-support/rpc-recording/adapters/diff-review-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/diff-review-mount-adapters.ts index 60481b1bd58..6d77ed68884 100644 --- a/mobile/src/test-support/rpc-recording/adapters/diff-review-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/diff-review-mount-adapters.ts @@ -6,10 +6,10 @@ const WORKTREE = 'repo-9::/w' const BRANCH_COMPARE = { summary: { baseRef: 'origin/main', - baseOid: 'base-oid', + baseOid: 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', compareRef: 'feature', - headOid: 'head-oid', - mergeBase: 'merge-base', + headOid: 'f0e1d2c3b4a5968778695a4b3c2d1e0f98765432', + mergeBase: '0123456789abcdef0123456789abcdef01234567', changedFiles: 1, status: 'ready' }, diff --git a/mobile/src/test-support/rpc-recording/adapters/push-registration-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/push-registration-mount-adapters.ts index 56e5253fb74..33101276a0d 100644 --- a/mobile/src/test-support/rpc-recording/adapters/push-registration-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/push-registration-mount-adapters.ts @@ -38,7 +38,8 @@ export function pushRegistrationMountAdapters( { platform: 'ios', token: 'apns-token-1', - ...(args.sandbox === true ? { apnsEnvironment: 'sandbox' } : {}) + // An iOS token always carries its APNs environment (`push-token.ts`). + apnsEnvironment: args.sandbox === true ? 'sandbox' : 'production' }, { onlyWhenDesktopAway: true, sound: args.sound !== false } ) diff --git a/mobile/src/test-support/rpc-recording/adapters/source-control-screen-read-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/source-control-screen-read-mount-adapters.ts index 9e1eaa40b02..c6bc6d6fec2 100644 --- a/mobile/src/test-support/rpc-recording/adapters/source-control-screen-read-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/source-control-screen-read-mount-adapters.ts @@ -192,10 +192,10 @@ const BRANCH_COMPARE_READY: MobileBranchCompareState = { result: mountFixture({ summary: { baseRef: 'origin/main', - baseOid: 'base-oid', + baseOid: 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', compareRef: 'feature', - headOid: 'head-oid', - mergeBase: 'merge-base', + headOid: 'f0e1d2c3b4a5968778695a4b3c2d1e0f98765432', + mergeBase: '0123456789abcdef0123456789abcdef01234567', changedFiles: 1, status: 'ready' }, diff --git a/mobile/src/test-support/rpc-recording/adapters/task-item-checks-status-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-item-checks-status-mount-adapters.ts index e6c96f7bb9c..d35cbc2a5b6 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-item-checks-status-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-item-checks-status-mount-adapters.ts @@ -93,7 +93,7 @@ const GITLAB_ISSUE_ITEM = { type: 'issue', state: 'opened', labels: ['bug'], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -107,7 +107,7 @@ const GITLAB_MR_ITEM = { type: 'mr', state: 'opened', labels: [], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -124,9 +124,7 @@ function gitlabDetailPayload(): Record { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-item-conversation-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-item-conversation-mount-adapters.ts index d4b2794a5e5..61dc744eed2 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-item-conversation-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-item-conversation-mount-adapters.ts @@ -46,7 +46,7 @@ const GITLAB_ISSUE_ITEM = { type: 'issue', state: 'opened', labels: ['bug'], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -60,7 +60,7 @@ const GITLAB_MR_ITEM = { type: 'mr', state: 'opened', labels: [], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -144,9 +144,7 @@ function gitlabDetailPayload(): Record { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-item-detail-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-item-detail-mount-adapters.ts index bf9b508b873..c51280c23a1 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-item-detail-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-item-detail-mount-adapters.ts @@ -31,7 +31,7 @@ const GITLAB_ISSUE_ITEM = { type: 'issue', state: 'opened', labels: ['bug'], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -57,9 +57,7 @@ const LINEAR_ITEM = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-item-hosted-metadata-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-item-hosted-metadata-mount-adapters.ts index bd932050411..c1e3551445e 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-item-hosted-metadata-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-item-hosted-metadata-mount-adapters.ts @@ -79,7 +79,7 @@ const GITLAB_ISSUE_ITEM = { type: 'issue', state: 'opened', labels: ['bug'], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -93,7 +93,7 @@ const GITLAB_MR_ITEM = { type: 'mr', state: 'opened', labels: [], - projectRef: 'group/project' + projectRef: { host: 'gitlab.com', path: 'group/project' } } } as const @@ -142,9 +142,7 @@ function linearDetailPayload(): Record { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-item-metadata-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-item-metadata-mount-adapters.ts index a1a06db4456..78585bff04e 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-item-metadata-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-item-metadata-mount-adapters.ts @@ -91,9 +91,7 @@ const LINEAR_ITEM = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-list-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-list-mount-adapters.ts index 683b2d75916..4dd3278c961 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-list-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-list-mount-adapters.ts @@ -11,9 +11,7 @@ const HOSTED_REPO = { id: REPO_ID, displayName: 'Repo', path: '/repo', provider: /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-project-board-load-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-project-board-load-mount-adapters.ts index 46f762b05cd..eb5645fe77c 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-project-board-load-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-project-board-load-mount-adapters.ts @@ -103,9 +103,7 @@ const PROJECT_TABLE = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-project-row-comment-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-project-row-comment-mount-adapters.ts index 137d927c5c3..54789eb83b7 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-project-row-comment-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-project-row-comment-mount-adapters.ts @@ -103,9 +103,7 @@ const PROJECT_TABLE = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-project-row-field-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-project-row-field-mount-adapters.ts index 36e10d4a949..5127f71c00e 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-project-row-field-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-project-row-field-mount-adapters.ts @@ -109,9 +109,7 @@ const PROJECT_TABLE = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-project-row-merge-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-project-row-merge-mount-adapters.ts index 4641b468972..6760bb6da2e 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-project-row-merge-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-project-row-merge-mount-adapters.ts @@ -133,9 +133,7 @@ const PROJECT_TABLE = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/adapters/task-project-row-read-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-project-row-read-mount-adapters.ts index 61aa616f8db..e0a0ece00cb 100644 --- a/mobile/src/test-support/rpc-recording/adapters/task-project-row-read-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/task-project-row-read-mount-adapters.ts @@ -102,9 +102,7 @@ const PROJECT_TABLE = { /** * One model in, an actions object out, every setter recorded as an effect: the shape this domain's - * hooks share. Copied per module rather than shared, because an adapter may not import another file - * in this directory: a golden pins the one module it was recorded through, so plumbing reaching - * across the seam would drive recordings its header does not cover. + * hooks share. */ type ModelHookSpec = { /** Called inside the render body, so a hook that throws is recorded as a mount failure. */ diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.test.ts b/mobile/src/test-support/rpc-recording/derived-goldens.test.ts index adaac841bc7..d2f0226d29f 100644 --- a/mobile/src/test-support/rpc-recording/derived-goldens.test.ts +++ b/mobile/src/test-support/rpc-recording/derived-goldens.test.ts @@ -13,13 +13,16 @@ const directory = process.env.RPC_FOUNDATION_GOLDENS ?? resolve(root, 'mobile/rpc-foundation/goldens') describe('derived goldens', () => { - // Fails closed both ways: a golden the derivation dropped stays frozen with nothing certifying or - // digesting it, and one it derives with no file on disk was never recorded. + // Fails closed both ways: a golden the derivation dropped stays on disk with nothing replaying it, + // and one it derives with no file on disk was never recorded. it('derives exactly the goldens on disk', () => { const derived = derivedGoldens(input.scenarios).map((golden) => golden.id) const onDisk = readdirSync(directory) .filter((file) => file.endsWith('.json')) .map((file) => file.replace(/\.json$/, '')) - expect(derived.sort()).toEqual(onDisk.sort()) + expect( + derived.sort(), + 'Record new goldens with `pnpm --dir mobile rpc:record`; delete ones the manifest no longer derives with `pnpm --dir mobile rpc:record --prune`' + ).toEqual(onDisk.sort()) }) }) diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.ts b/mobile/src/test-support/rpc-recording/derived-goldens.ts index d372436b8d3..67ffa4a7e13 100644 --- a/mobile/src/test-support/rpc-recording/derived-goldens.ts +++ b/mobile/src/test-support/rpc-recording/derived-goldens.ts @@ -10,10 +10,11 @@ import { import type { RecordingScenario } from './recording-scenario' /** - * One frozen golden and the scenarios it is recorded from. Derived here rather than inside the - * suites so `scenarioSha256` is a function of the same derivation that records the file: a test - * that restated how a matrix or schedule expands could agree with itself while disagreeing with - * what was recorded. + * One golden and the scenarios it is recorded from. Derived here rather than inside the suites so + * the census and `rpc:record --prune` read the same derivation that records the file: a restated + * expansion could agree with itself while disagreeing with what was recorded. + * + * Every `title` starts with the golden id, so `rpc:record ` can select it by test name. */ export type DerivedGolden = { id: string @@ -55,11 +56,11 @@ function baseScenario(manifest: readonly RecordingScenario[], id: string): Recor /** The manifest scenario a pilot golden expands from, which its suite also mounts and mutates. */ export type PilotGolden = DerivedGolden & { scenario: RecordingScenario } -/** One golden per manifest scenario: frozen main parity for the scenario as written. */ +/** One golden per manifest scenario, recorded as written. */ export function pilotGoldens(manifest: readonly RecordingScenario[]): PilotGolden[] { return manifest.map((scenario) => ({ id: scenario.id, - title: `${scenario.id}: frozen main parity and determinism`, + title: `${scenario.id}: replays its recording deterministically`, family: scenario.family, scenario, scenarios: () => [scenario] @@ -87,7 +88,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo ids.add(id) goldens.push({ id, - title: `${family}: reply partitions at ${site}`, + title: `${id}: ${family} reply partitions at ${site}`, family, site, timeoutMs: 30_000, @@ -112,7 +113,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo } goldens.push({ id: `schedules-${id}`, - title: `${id}: completion orders and correlated faults`, + title: `schedules-${id}: completion orders and correlated faults`, family: base.family, scenarios: () => siblingSchedules(base, first, second) }) @@ -121,7 +122,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo const base = baseScenario(manifest, id) goldens.push({ id: `interruptions-${id}`, - title: `${id}: timeout, disconnect and stable-client cutover`, + title: `interruptions-${id}: timeout, disconnect and stable-client cutover`, family: base.family, scenarios: () => interruptionSchedules(base) }) @@ -135,7 +136,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo : ['reset', 'unmount', 'blur'] goldens.push({ id: `lifecycle-${id}`, - title: `${id}: lifecycle boundaries`, + title: `lifecycle-${id}: lifecycle boundaries`, family: base.family, scenarios: () => hoistPreludeCheckpoints( diff --git a/mobile/src/test-support/rpc-recording/family-recordings.test.ts b/mobile/src/test-support/rpc-recording/family-recordings.test.ts index 497f5809ec6..1680bf769a5 100644 --- a/mobile/src/test-support/rpc-recording/family-recordings.test.ts +++ b/mobile/src/test-support/rpc-recording/family-recordings.test.ts @@ -6,13 +6,7 @@ import { REPLY_MATRIX_NORMAL_RESULT_INVENTORY } from './reply-matrix-normal-resu import { runRecording } from './run-recording' import { pilotMountAdapters } from './pilot-mount-adapters' import { vitestRecordingScheduler } from './vitest-recording-scheduler' -import { - compareGolden, - goldenBytes, - goldenRecording, - readGolden, - writeGolden -} from './golden-recording' +import { expectGoldenFile, goldenBytes, goldenRecording, writeGolden } from './golden-recording' import type { Recording, RecordingScenario } from './recording-scenario' import { determinismRuns } from './determinism-runs' @@ -38,10 +32,7 @@ async function certify(id: string, scenarios: RecordingScenario[]) { checkpoints.push({ ...checkpoint, id: `${scenario.id}:${checkpoint.id}` }) } } - const golden = goldenRecording(root, input.baseline, scenarios, { - scenario: id, - checkpoints - }) + const golden = goldenRecording(scenarios, { scenario: id, checkpoints }) const bytes = goldenBytes(golden) if (run) { expect(bytes).toBe(first) @@ -50,7 +41,7 @@ async function certify(id: string, scenarios: RecordingScenario[]) { if (process.env.RPC_FOUNDATION_MODE === '--record') { await writeGolden(directory, golden, '--record') } else { - compareGolden(readGolden(directory, id), golden) + await expectGoldenFile(directory, id, golden) } } } diff --git a/mobile/src/test-support/rpc-recording/golden-difference.ts b/mobile/src/test-support/rpc-recording/golden-difference.ts new file mode 100644 index 00000000000..e0229890d83 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/golden-difference.ts @@ -0,0 +1,205 @@ +import { + canonicalJson, + OBSERVATION_FIELDS, + resolveRecording, + type InternedRecording, + type ValuePool +} from './golden-value-pool' +import type { Recording } from './recording-scenario' +import type { RecordedValue } from './recording-values' + +/** What a golden says about behaviour, whatever else its file format carries alongside. */ +export type DecodedGolden = { + operation: string + family: string + namedDeltas: string[] + recording: Recording +} + +/** One place a recording differs: a checkpoint, an observation field, and the JSON path inside it. */ +export type RecordingDifference = { + checkpoint: string + field: string + path: string + expected: RecordedValue + actual: RecordedValue +} + +/** The same difference seen at several checkpoints, which an append-only history repeats. */ +export type GroupedDifference = Omit & { checkpoints: string[] } + +export const IDENTITY_FIELDS = ['operation', 'family', 'namedDeltas'] as const + +/** + * Resolves a pooled golden without judging its format version, so a diff can read both sides of a + * format change. `readGolden` is the strict reader the recording suites use. + */ +export function decodeGoldenFile(file: unknown, id: string): DecodedGolden { + if (file === null || typeof file !== 'object' || Array.isArray(file)) { + throw new Error(`Golden ${id} is not a JSON object`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guard above rejected null, arrays and primitives; each member is checked below. + const { operation, family, namedDeltas, values, recording } = file as Record + if (typeof operation !== 'string' || typeof family !== 'string' || !Array.isArray(namedDeltas)) { + throw new Error(`Golden ${id} is missing its operation, family or namedDeltas`) + } + if (!values || typeof values !== 'object' || !recording || typeof recording !== 'object') { + throw new Error(`Golden ${id} is missing its value pool or recording`) + } + return { + operation, + family, + namedDeltas: namedDeltas.map(String), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveRecording validates every hash reference and field container it reads. + recording: resolveRecording(values as ValuePool, recording as InternedRecording) + } +} + +export function firstDifference( + expected: RecordedValue, + actual: RecordedValue, + path = '' +): { path: string; expected: RecordedValue; actual: RecordedValue } { + const here = { path, expected, actual } + if ( + expected === null || + actual === null || + typeof expected !== 'object' || + typeof actual !== 'object' || + Array.isArray(expected) !== Array.isArray(actual) + ) { + return here + } + if (Array.isArray(expected) && Array.isArray(actual)) { + const index = expected.findIndex( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. + (entry, at) => canonicalJson(entry) !== canonicalJson(actual[at] as RecordedValue) + ) + return index === -1 || index >= actual.length + ? here + : firstDifference( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. + expected[index] as RecordedValue, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. + actual[index] as RecordedValue, + `${path}[${index}]` + ) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the array branch above already rejected a non-object pair. + const left = expected as Record + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the array branch above already rejected a non-object pair. + const right = actual as Record + const key = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort().find( + (name) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. + canonicalJson(left[name] as RecordedValue) !== canonicalJson(right[name] as RecordedValue) + ) + return key === undefined || !(key in left) || !(key in right) + ? here + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. + firstDifference(left[key] as RecordedValue, right[key] as RecordedValue, `${path}.${key}`) +} + +export function excerpt(value: RecordedValue, limit = 600): string { + const json = JSON.stringify(value) + return json === undefined ? 'absent' : json.length > limit ? `${json.slice(0, limit)}…` : json +} + +/** + * Checkpoint names keyed by occurrence, because an id may repeat within one golden + * (`tk-list-linear` records `load-settled` twice). A repeat is shown as `id#2`. + */ +function checkpointKeys(recording: Recording): string[] { + const seen = new Map() + return recording.checkpoints.map((checkpoint) => { + const occurrence = (seen.get(checkpoint.id) ?? 0) + 1 + seen.set(checkpoint.id, occurrence) + return occurrence === 1 ? checkpoint.id : `${checkpoint.id}#${occurrence}` + }) +} + +/** Checkpoints only one side records, and whether the shared ones kept their order. */ +export function checkpointListDifference( + expected: Recording, + actual: Recording +): { missing: string[]; extra: string[]; reordered: boolean } { + const expectedKeys = checkpointKeys(expected) + const actualKeys = checkpointKeys(actual) + const missing = expectedKeys.filter((key) => !actualKeys.includes(key)) + const extra = actualKeys.filter((key) => !expectedKeys.includes(key)) + const shared = (keys: string[]) => + keys.filter((key) => expectedKeys.includes(key) && actualKeys.includes(key)) + return { + missing, + extra, + reordered: JSON.stringify(shared(expectedKeys)) !== JSON.stringify(shared(actualKeys)) + } +} + +/** Every field that differs at every checkpoint both sides record, each at its first differing path. */ +export function recordingDifferences( + expected: Recording, + actual: Recording +): RecordingDifference[] { + const actualKeys = checkpointKeys(actual) + const actualByKey = new Map( + actual.checkpoints.map((checkpoint, index) => [actualKeys[index]!, checkpoint]) + ) + const differences: RecordingDifference[] = [] + for (const [index, key] of checkpointKeys(expected).entries()) { + const checkpoint = expected.checkpoints[index]! + const found = actualByKey.get(key) + if (!found) { + continue + } + for (const field of OBSERVATION_FIELDS) { + const left = checkpoint.observation[field] + const right = found.observation[field] + if (canonicalJson(left) === canonicalJson(right)) { + continue + } + const at = firstDifference(left, right) + differences.push({ + checkpoint: key, + field, + path: at.path, + expected: at.expected, + actual: at.actual + }) + } + } + return differences +} + +/** Why grouped: a moved early entry re-appears in every later checkpoint of an append-only field. */ +export function groupDifferences(differences: RecordingDifference[]): GroupedDifference[] { + const groups = new Map() + for (const difference of differences) { + const key = [ + difference.field, + difference.path, + canonicalJson(difference.expected), + canonicalJson(difference.actual) + ].join('\0') + const group = groups.get(key) + if (group) { + group.checkpoints.push(difference.checkpoint) + } else { + const { checkpoint, ...rest } = difference + groups.set(key, { ...rest, checkpoints: [checkpoint] }) + } + } + return [...groups.values()] +} + +/** A golden's identity fields that differ, named so a failure says which one moved. */ +export function identityDifferences( + expected: DecodedGolden, + actual: DecodedGolden +): { field: (typeof IDENTITY_FIELDS)[number]; expected: unknown; actual: unknown }[] { + return IDENTITY_FIELDS.flatMap((field) => + JSON.stringify(expected[field]) === JSON.stringify(actual[field]) + ? [] + : [{ field, expected: expected[field], actual: actual[field] }] + ) +} diff --git a/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts b/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts deleted file mode 100644 index dd67b564c2d..00000000000 --- a/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { afterAll, describe, expect, it } from 'vitest' -import { derivedGoldens } from './derived-goldens' -import { goldenRecording, type GoldenRecording } from './golden-recording' -import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' -import { ADAPTER_DIRECTORY, RECORDER_DIRECTORY } from './recorder-digest' -import { readScenarios } from './scenario-input' -import type { MountedOperationModule } from './mounted-operation-module' -import type { RecordingScenario, ScenarioStep } from './recording-scenario' - -/** Spelled, not imported: a rename of the excluded directory must fail this suite, not follow it. */ -const MUTANT_DIRECTORY = `${RECORDER_DIRECTORY}/mutants` -const root = resolve(import.meta.dirname, '../../../..') -const manifest = readScenarios( - process.env.RPC_FOUNDATION_SCENARIOS ?? - resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json') -).scenarios -const BASELINE = 'a'.repeat(40) -const EDITED_SCENARIO = 'b1' -const EDITED_SITE = 'files.searchPaths#1' -/** The only `legacy-inventory` scenario with a fulfilled reply at `EDITED_SITE`. */ -const REPLAYED_SCENARIO = 'inventory-repeat-query' -const REPLAYED_GOLDEN = 'matrix-legacy-inventory-files.searchpaths-1' -/** - * Every golden derived from `b1`: its own, and its family's four matrix sites, which expand from it - * as the family's base. The two other `legacy-inventory` scenarios and the interruption and - * lifecycle goldens that expand from `inventory-lifecycle` are deliberately absent. - */ -const EDITED_GOLDENS = [ - 'b1', - 'matrix-legacy-inventory-files.searchpaths-1', - 'matrix-legacy-inventory-files.searchpaths-2', - 'matrix-legacy-inventory-fresh-inventory', - 'matrix-legacy-inventory-old-inventory' -] -type Header = Omit - -const created: string[] = [] -afterAll(() => { - for (const directory of created) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -/** - * One state of the recorder tree: the engine, the registered adapter modules, the source each one - * holds, and the mutant evidence beside them. A module left out of `sources` gets identical stub - * source, so only what a revision names is different between two of them. - */ -type Revision = { - engine: string - registered?: readonly MountedOperationModule[] - sources?: Record - mutants?: Record -} - -/** The files a root contributes to a header, plus the manifest the oldest digest also read. */ -function stubRoot(revision: Revision, scenarioFile: string): string { - const directory = mkdtempSync(join(tmpdir(), 'rpc-header-')) - created.push(directory) - mkdirSync(join(directory, ADAPTER_DIRECTORY), { recursive: true }) - writeFileSync(join(directory, RECORDER_DIRECTORY, 'runner.ts'), revision.engine) - for (const { source } of revision.registered ?? MOUNTED_OPERATION_MODULES) { - const stub = revision.sources?.[source] ?? 'export const adapter = 1' - writeFileSync(join(directory, ADAPTER_DIRECTORY, source), stub) - } - mkdirSync(join(directory, MUTANT_DIRECTORY), { recursive: true }) - for (const [file, source] of Object.entries(revision.mutants ?? {})) { - writeFileSync(join(directory, MUTANT_DIRECTORY, file), source) - } - writeFileSync(join(directory, 'mobile/pnpm-lock.yaml'), 'lockfile: stub\n') - mkdirSync(join(directory, 'mobile/rpc-foundation'), { recursive: true }) - writeFileSync(join(directory, 'mobile/rpc-foundation/pilot-scenarios.json'), scenarioFile) - return directory -} - -/** Every golden's header for one recorder revision and one manifest, both written to a stub root. */ -function headers(revision: Revision, scenarios: readonly RecordingScenario[]): Map { - const stub = stubRoot(revision, JSON.stringify({ baseline: BASELINE, scenarios })) - return new Map( - derivedGoldens(scenarios).map((golden) => { - const { recording: _recording, ...header } = goldenRecording( - stub, - BASELINE, - golden.scenarios(), - { scenario: golden.id, checkpoints: [] }, - revision.registered ?? MOUNTED_OPERATION_MODULES - ) - return [golden.id, header] - }) - ) -} - -function moved(before: Map, after: Map): string[] { - return [...before] - .filter(([id, header]) => JSON.stringify(after.get(id)) !== JSON.stringify(header)) - .map(([id]) => id) - .sort() -} - -const ADDED_MODULE: MountedOperationModule = { - source: 'digest-probe-mount-adapters.ts', - mounts: () => ({ - 'digest.probe': () => { - throw new Error('The digest reads adapter source; it never mounts one') - } - }) -} - -/** Every golden recorded through `new-tab-agent-mount-adapters.ts`, the module with one operation. */ -const NEW_TAB_GOLDENS = [ - 'matrix-settings-agent-read-preflight.detectremoteagents-1', - 'matrix-settings-agent-read-repo.list-1', - 'matrix-settings-agent-read-settings.get-1', - 'matrix-settings.new-tab-local-agents-preflight.detectagents-1', - 'matrix-settings.new-tab-local-agents-repo.list-1', - 'matrix-settings.new-tab-local-agents-settings.get-1', - 'new-tab-local-agents', - 'probe-new-tab-both-refused', - 'probe-new-tab-null-sibling-refused', - 'probe-new-tab-refused-sibling-rejects', - 'probe-new-tab-rejects-sibling-refused', - 'schedules-settings-new-tab-ssh', - 'settings-new-tab-refused', - 'settings-new-tab-ssh', - 'settings-new-tab-transport-error' -] - -/** A family no other golden consumes, with one reply the matrix can replay as its success. */ -const ADDED_FAMILY: RecordingScenario = { - id: 'digest-probe', - operation: 'digest.probe', - version: 1, - family: 'digest-probe', - sites: [], - schedules: ['probe'], - steps: [ - { - complete: 'probe.read#1', - params: { worktree: 'id:A' }, - reply: { ok: true, result: { probed: true } } - }, - { checkpoint: 'settled' } - ] -} - -type Completion = Extract - -/** Rewrites one named completion, and fails loudly if the step it names has moved or multiplied. */ -function editCompletion( - scenarios: readonly RecordingScenario[], - scenarioId: string, - request: string, - rewrite: (step: Completion) => Completion -): RecordingScenario[] { - let edits = 0 - const edited = scenarios.map((scenario) => - scenario.id !== scenarioId - ? scenario - : { - ...scenario, - steps: scenario.steps.map((step) => { - if (!('complete' in step) || step.complete !== request) { - return step - } - edits++ - return rewrite(step) - }) - } - ) - if (edits !== 1) { - throw new Error(`Expected one ${request} completion in ${scenarioId}, edited ${edits}`) - } - return edited -} - -describe('golden header digests', () => { - const ENGINE = 'export const runner = 1' - - // A whole domain PR: a family, the module that mounts it, and the mutant that proves its - // projection load-bearing. None is an input to any other golden's header, so nothing already - // recorded re-records and two such branches conflict on no golden line at all. - it('re-digests nothing when a domain adds a family, an adapter module and a mutant', () => { - const before = headers( - { engine: ENGINE, mutants: { 'operation-mutations.ts': 'one' } }, - manifest - ) - const after = headers( - { - engine: ENGINE, - registered: [...MOUNTED_OPERATION_MODULES, ADDED_MODULE], - mutants: { 'operation-mutations.ts': 'two', 'digest-probe-mutants.test.ts': 'added' } - }, - [...manifest, ADDED_FAMILY] - ) - expect(moved(before, after)).toEqual([]) - // The added family did derive goldens of its own: a pilot golden and one matrix site. - expect(after.size).toBe(before.size + 2) - }) - - it('re-digests exactly the goldens recorded through an edited adapter module', () => { - const before = headers({ engine: ENGINE }, manifest) - const after = headers( - { - engine: ENGINE, - sources: { 'new-tab-agent-mount-adapters.ts': 'export const adapter = 2' } - }, - manifest - ) - expect(moved(before, after)).toEqual([...NEW_TAB_GOLDENS].sort()) - for (const id of NEW_TAB_GOLDENS) { - expect(after.get(id)?.recorderSha256).toBe(before.get(id)?.recorderSha256) - expect(after.get(id)?.scenarioSha256).toBe(before.get(id)?.scenarioSha256) - expect(after.get(id)?.adapterSha256).not.toBe(before.get(id)?.adapterSha256) - } - }) - - it('re-digests exactly the goldens derived from an edited scenario', () => { - const before = headers({ engine: ENGINE }, manifest) - const after = headers( - { engine: ENGINE }, - editCompletion(manifest, EDITED_SCENARIO, EDITED_SITE, (step) => ({ - ...step, - params: { worktree: 'id:A', query: 'old', limit: 17 } - })) - ) - expect(moved(before, after)).toEqual([...EDITED_GOLDENS].sort()) - for (const id of EDITED_GOLDENS) { - expect(after.get(id)?.recorderSha256).toBe(before.get(id)?.recorderSha256) - expect(after.get(id)?.adapterSha256).toBe(before.get(id)?.adapterSha256) - expect(after.get(id)?.scenarioSha256).not.toBe(before.get(id)?.scenarioSha256) - } - }) - - // The generated variants are hashed, not the base they expand from, and this is what that buys: - // the `normal` partition replays a sibling's recorded reply, so the sibling is a real input to a - // matrix golden that its own scenario never appears in. - it('re-digests a matrix golden whose replayed success comes from an edited sibling', () => { - const before = headers({ engine: ENGINE }, manifest) - const after = headers( - { engine: ENGINE }, - editCompletion(manifest, REPLAYED_SCENARIO, EDITED_SITE, (step) => ({ - ...step, - reply: { ok: true, result: { files: [{ relativePath: 'edited.ts' }] } } - })) - ) - expect(moved(before, after)).toEqual([REPLAYED_SCENARIO, REPLAYED_GOLDEN].sort()) - }) - - it('re-digests every golden when an engine file changes', () => { - const before = headers({ engine: ENGINE }, manifest) - const after = headers({ engine: 'export const runner = 2' }, manifest) - expect(moved(before, after)).toEqual([...before.keys()].sort()) - for (const [id, header] of before) { - expect(after.get(id)?.recorderSha256).not.toBe(header.recorderSha256) - expect(after.get(id)?.adapterSha256).toBe(header.adapterSha256) - expect(after.get(id)?.scenarioSha256).toBe(header.scenarioSha256) - } - }) -}) diff --git a/mobile/src/test-support/rpc-recording/golden-recording.ts b/mobile/src/test-support/rpc-recording/golden-recording.ts index 978a0cc4380..684ce258ac2 100644 --- a/mobile/src/test-support/rpc-recording/golden-recording.ts +++ b/mobile/src/test-support/rpc-recording/golden-recording.ts @@ -1,76 +1,46 @@ import { format } from 'oxfmt' -import { createHash } from 'node:crypto' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { - canonicalJson, - internRecording, - OBSERVATION_FIELDS, - resolveRecording, - type InternedRecording, - type ValuePool -} from './golden-value-pool' -import { adapterSha256 } from './adapter-digest' -import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' -import { recorderSha256 } from './recorder-digest' -import { scenarioSha256 } from './scenario-digest' -import type { MountedOperationModule } from './mounted-operation-module' + checkpointListDifference, + decodeGoldenFile, + excerpt, + groupDifferences, + identityDifferences, + recordingDifferences +} from './golden-difference' +import { internRecording } from './golden-value-pool' import type { Recording, RecordingScenario } from './recording-scenario' -import type { RecordedValue } from './recording-values' -export const RUNNER_VERSION = 1 -// 2 stamps every settlement with startedAt/settledAt on the pinned virtual clock. -export const PROJECTION_VERSION = 2 -// 5 splits the mount adapters out of recorderSha256 into adapterSha256. As with 4, the byte compare -// would fail a stale golden anyway; the bump buys the diagnosis instead of an opaque `(encoding)`. -export const GOLDEN_FORMAT_VERSION = 5 +// 6 drops the provenance header (baseline pin, input digests, lockfile, platform, runner, projection +// and scenario versions): every run re-derives the recording from the current tree and compares it. +export const GOLDEN_FORMAT_VERSION = 6 +/** How many grouped differences one failure prints in full. */ +const REPORTED_DIFFERENCES = 8 export type GoldenRecording = { + goldenFormatVersion: number operation: string family: string namedDeltas: string[] - runnerVersion: number - baseline: string - lockfileSha256: string - recorderSha256: string - adapterSha256: string - scenarioSha256: string - platform: string - scenarioVersion: number - projectionVersion: number - goldenFormatVersion: number recording: Recording } -type GoldenFile = Omit & { - values: ValuePool - recording: InternedRecording + +export function recordHint(id: string): string { + return `If the change is intended, re-record it: pnpm --dir mobile rpc:record ${id}` } export function goldenRecording( - root: string, - baseline: string, scenarios: readonly RecordingScenario[], - recording: Recording, - registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES + recording: Recording ): GoldenRecording { const [scenario] = scenarios if (!scenario) { throw new Error('A golden records at least one scenario') } return { + goldenFormatVersion: GOLDEN_FORMAT_VERSION, operation: scenario.operation, family: scenario.family, namedDeltas: scenario.namedDeltas ?? [], - runnerVersion: RUNNER_VERSION, - baseline, - lockfileSha256: createHash('sha256') - .update(readFileSync(join(root, 'mobile/pnpm-lock.yaml'))) - .digest('hex'), - recorderSha256: recorderSha256(root), - adapterSha256: adapterSha256(root, scenarios, registered), - scenarioSha256: scenarioSha256(scenarios), - platform: process.platform, - scenarioVersion: scenario.version, - projectionVersion: PROJECTION_VERSION, - goldenFormatVersion: GOLDEN_FORMAT_VERSION, recording } } @@ -80,35 +50,56 @@ export function goldenBytes(golden: GoldenRecording): string { return `${JSON.stringify({ ...header, values: interned.values, recording: interned.recording }, null, 2)}\n` } export function readGolden(directory: string, id: string): GoldenRecording { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the file is validated against GoldenFile on the next lines. - const file = JSON.parse(readFileSync(goldenPath(directory, id), 'utf8')) as Partial - if (file.goldenFormatVersion !== GOLDEN_FORMAT_VERSION) { + const path = goldenPath(directory, id) + if (!existsSync(path)) { + throw new Error(`No golden recorded for ${id}. Record it: pnpm --dir mobile rpc:record ${id}`) + } + const file: unknown = withRecordHint(id, () => JSON.parse(readFileSync(path, 'utf8'))) + const version = + file && typeof file === 'object' && 'goldenFormatVersion' in file + ? file.goldenFormatVersion + : undefined + if (version !== GOLDEN_FORMAT_VERSION) { throw new Error( - `Golden ${id} has format version ${JSON.stringify(file.goldenFormatVersion)}; this reader requires ${GOLDEN_FORMAT_VERSION}. Re-record with --record.` + `Golden ${id} has format version ${JSON.stringify(version)}; this reader requires ${GOLDEN_FORMAT_VERSION}.\n${recordHint(id)}` ) } - if (!file.values || !file.recording) { - throw new Error(`Golden ${id} is missing its value pool or recording`) + return withRecordHint(id, () => ({ + goldenFormatVersion: GOLDEN_FORMAT_VERSION, + ...decodeGoldenFile(file, id) + })) +} +/** A corrupt or hand-edited golden names itself and the command that rewrites it. */ +function withRecordHint(id: string, read: () => T): T { + try { + return read() + } catch (error) { + throw new Error( + `Golden ${id}: ${error instanceof Error ? error.message : String(error)}\n${recordHint(id)}` + ) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: goldenFormatVersion was just checked, so the pool is present. - const { values: _pool, ...header } = file as GoldenFile - return { ...header, recording: resolveRecording(file.values, file.recording) } } export async function writeGolden( directory: string, golden: GoldenRecording, mode: string ): Promise { - if (mode !== '--record' || process.env.RPC_FOUNDATION_RECORD !== '1') { - throw new Error('Golden writes require --record and RPC_FOUNDATION_RECORD=1') + if (mode !== '--record') { + throw new Error('Golden writes require --record') } mkdirSync(directory, { recursive: true }) - const path = goldenPath(directory, golden.recording.scenario) - const result = await format(path, goldenBytes(golden), { printWidth: 100, trailingComma: 'none' }) + writeFileSync(goldenPath(directory, golden.recording.scenario), await goldenFileText(golden)) +} +/** The exact file text `rpc:record` writes for a golden. */ +async function goldenFileText(golden: GoldenRecording): Promise { + const result = await format(`${golden.recording.scenario}.json`, goldenBytes(golden), { + printWidth: 100, + trailingComma: 'none' + }) if (result.errors.length) { throw new Error('Cannot format golden') } - writeFileSync(path, result.code) + return result.code } function goldenPath(directory: string, id: string): string { if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) { @@ -116,93 +107,68 @@ function goldenPath(directory: string, id: string): string { } return join(directory, `${id}.json`) } +/** + * Replay passes only if the committed file is exactly what `rpc:record` would write for this run, so + * nothing the file carries (a leftover key, a stale pool entry, a hand edit) goes uncompared. + */ +export async function expectGoldenFile( + directory: string, + id: string, + actual: GoldenRecording +): Promise { + const path = goldenPath(directory, id) + if (existsSync(path) && readFileSync(path, 'utf8') === (await goldenFileText(actual))) { + return + } + const problems = recordingProblems(readGolden(directory, id), actual) + throw problems.length + ? recordingDiffers(id, problems) + : new Error( + `Golden ${id} holds the same recording but is not the file rpc:record writes for it (a hand edit, a leftover key, a stale pool entry, or keys in another order).\n${recordHint(id)}` + ) +} +/** Value-based compare for a run checked against decoded values rather than a committed file. */ export function compareGolden(expected: GoldenRecording, actual: GoldenRecording): void { - const scenario = actual.recording.scenario - // Platform and lockfile are provenance: a dependency that changes behaviour changes the trace - // below, and one that does not must not fail the compare on every unrelated bump. - const pinned = { ...expected, platform: actual.platform, lockfileSha256: actual.lockfileSha256 } - const { recording: _expectedRecording, ...expectedHeader } = pinned - const { recording: _actualRecording, ...actualHeader } = actual - for (const [key, value] of Object.entries(expectedHeader)) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: header keys are compared as data, not consumed as fields. - const found = (actualHeader as Record)[key] - if (JSON.stringify(found) !== JSON.stringify(value)) { - throw new Error( - `Recording differs: ${scenario} header ${key}\n expected ${JSON.stringify(value)}\n actual ${JSON.stringify(found)}` - ) - } + const problems = recordingProblems(expected, actual) + // The field compares ignore key order and the re-encoded bytes do not, so the bytes decide last. + if (!problems.length && goldenBytes(expected) !== goldenBytes(actual)) { + problems.push('encoding: every field matches but the encoded bytes do not') } - const expectedIds = pinned.recording.checkpoints.map((checkpoint) => checkpoint.id) - const actualIds = actual.recording.checkpoints.map((checkpoint) => checkpoint.id) - if (JSON.stringify(expectedIds) !== JSON.stringify(actualIds)) { - const index = expectedIds.findIndex((id, at) => id !== actualIds[at]) - throw new Error( - `Recording differs: ${scenario} checkpoint list (${expectedIds.length} expected, ${actualIds.length} actual)\n first divergence at index ${index}: expected ${JSON.stringify(expectedIds[index])}, actual ${JSON.stringify(actualIds[index])}` - ) - } - for (const [index, checkpoint] of pinned.recording.checkpoints.entries()) { - const found = actual.recording.checkpoints[index]! - for (const field of OBSERVATION_FIELDS) { - if ( - canonicalJson(checkpoint.observation[field]) === canonicalJson(found.observation[field]) - ) { - continue - } - const path = firstDifference(checkpoint.observation[field], found.observation[field]) - throw new Error( - `Recording differs: ${scenario} checkpoint ${checkpoint.id} field ${field}${path.path}\n expected ${excerpt(path.expected)}\n actual ${excerpt(path.actual)}` - ) - } - } - if (goldenBytes(pinned) !== goldenBytes(actual)) { - throw new Error(`Recording differs: ${scenario} (encoding)`) + if (problems.length) { + throw recordingDiffers(actual.recording.scenario, problems) } } -export function firstDifference( - expected: RecordedValue, - actual: RecordedValue, - path = '' -): { path: string; expected: RecordedValue; actual: RecordedValue } { - const here = { path, expected, actual } - if ( - expected === null || - actual === null || - typeof expected !== 'object' || - typeof actual !== 'object' || - Array.isArray(expected) !== Array.isArray(actual) - ) { - return here - } - if (Array.isArray(expected) && Array.isArray(actual)) { - const index = expected.findIndex( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. - (entry, at) => canonicalJson(entry) !== canonicalJson(actual[at] as RecordedValue) - ) - return index === -1 || index >= actual.length - ? here - : firstDifference( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. - expected[index] as RecordedValue, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. - actual[index] as RecordedValue, - `${path}[${index}]` - ) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the array branch above already rejected a non-object pair. - const left = expected as Record - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the array branch above already rejected a non-object pair. - const right = actual as Record - const key = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort().find( - (name) => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. - canonicalJson(left[name] as RecordedValue) !== canonicalJson(right[name] as RecordedValue) +function recordingDiffers(scenario: string, problems: readonly string[]): Error { + return new Error( + `Recording differs: ${scenario}\n ${problems.join('\n ')}\n${recordHint(scenario)}` ) - return key === undefined || !(key in left) || !(key in right) - ? here - : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both sides are recorded observations, so every member is a RecordedValue. - firstDifference(left[key] as RecordedValue, right[key] as RecordedValue, `${path}.${key}`) } -function excerpt(value: RecordedValue): string { - const json = JSON.stringify(value) - return json === undefined ? 'absent' : json.length > 600 ? `${json.slice(0, 600)}…` : json +function recordingProblems(expected: GoldenRecording, actual: GoldenRecording): string[] { + const problems: string[] = identityDifferences(expected, actual).map( + (moved) => + `${moved.field}\n expected ${JSON.stringify(moved.expected)}\n actual ${JSON.stringify(moved.actual)}` + ) + const list = checkpointListDifference(expected.recording, actual.recording) + if (list.missing.length || list.extra.length || list.reordered) { + problems.push( + [ + 'checkpoint list', + ...(list.missing.length ? [` no longer recorded: ${list.missing.join(', ')}`] : []), + ...(list.extra.length ? [` newly recorded: ${list.extra.join(', ')}`] : []), + ...(list.reordered ? [' shared checkpoints recorded in a different order'] : []) + ].join('\n') + ) + } + const differences = groupDifferences(recordingDifferences(expected.recording, actual.recording)) + for (const difference of differences.slice(0, REPORTED_DIFFERENCES)) { + const [first, ...rest] = difference.checkpoints + const also = rest.length ? ` (and ${rest.length} later)` : '' + problems.push( + `checkpoint ${first} field ${difference.field}${difference.path}${also}\n expected ${excerpt(difference.expected)}\n actual ${excerpt(difference.actual)}` + ) + } + if (differences.length > REPORTED_DIFFERENCES) { + problems.push(`… ${differences.length - REPORTED_DIFFERENCES} more differences`) + } + return problems } diff --git a/mobile/src/test-support/rpc-recording/host-client-context-exposure.ts b/mobile/src/test-support/rpc-recording/host-client-context-exposure.ts index a3fac9b54b7..1acd34c2010 100644 --- a/mobile/src/test-support/rpc-recording/host-client-context-exposure.ts +++ b/mobile/src/test-support/rpc-recording/host-client-context-exposure.ts @@ -18,8 +18,6 @@ const HOST_CLIENT_CONTEXT_MODULE = 'mobile/src/transport/client-context.tsx' * an import, so no type checker sees it and a rename of the local surfaces as a `ReferenceError` * mid-recording; five hand-copied spellings are five independent ways to arrive there, and * `adapter-seam.test.ts` both pins the local against the product source and refuses a sixth copy. - * The cost is where the pin lives: this text is inside `recorderSha256`, so editing it re-records - * the whole corpus rather than the five families that mount through it. */ export const hostClientContextExposure: OperationExposure = [ 'client-context.tsx', diff --git a/mobile/src/test-support/rpc-recording/mounted-operation-module.ts b/mobile/src/test-support/rpc-recording/mounted-operation-module.ts index 4a2e326665c..308fd26e26e 100644 --- a/mobile/src/test-support/rpc-recording/mounted-operation-module.ts +++ b/mobile/src/test-support/rpc-recording/mounted-operation-module.ts @@ -4,13 +4,10 @@ import type { MountAdapter } from './recording-scenario' /** Reference mode wires an archived tree's operations instead of main's. */ export type MountOptions = { reference?: boolean } -/** - * One domain's mount adapters and the `adapters/` file they live in. The file is what each golden - * recorded through them pins, so a domain edit moves those goldens and no others. - */ +/** One domain's mount adapters and the `adapters/` file they live in. */ export type MountedOperationModule = { source: string - /** Module-private product exports this domain's adapters drive. Declared in `source`, so pinned. */ + /** Module-private product exports this domain's adapters drive, declared in `source`. */ exposes?: readonly OperationExposure[] mounts: ( modules: ReturnType, diff --git a/mobile/src/test-support/rpc-recording/mutants/mutant-seam.test.ts b/mobile/src/test-support/rpc-recording/mutants/mutant-seam.test.ts index 396765a9ea0..52c24296695 100644 --- a/mobile/src/test-support/rpc-recording/mutants/mutant-seam.test.ts +++ b/mobile/src/test-support/rpc-recording/mutants/mutant-seam.test.ts @@ -1,16 +1,13 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' -import { RECORDER_DIRECTORY } from '../recorder-digest' import { RECORDING_DRIVERS } from '../recording-drivers' const root = resolve(import.meta.dirname, '../../../../..') -const recorder = join(root, RECORDER_DIRECTORY) -const mutants = join(root, RECORDER_DIRECTORY, 'mutants') -/** The one file allowed to name this directory: it names it in order to exclude it. */ -const EXCLUDER = 'recorder-digest.ts' -/** Both spellings of the path, so a constant is no more usable than the literal. */ -const NAMES = ['mutants', 'MUTANT_DIRECTORY'] +const recorder = join(root, 'mobile/src/test-support/rpc-recording') +const mutants = join(recorder, 'mutants') +/** Names the drivers for the record script and the suites that check them; no recording loads it. */ +const OFF_RECORDING_PATH = ['recording-drivers.ts'] function sources(directory: string): string[] { return readdirSync(directory, { withFileTypes: true, recursive: true }) @@ -59,12 +56,11 @@ function reachable(entries: readonly string[]): Set { } /** - * `recorderSha256` skips this directory, so nothing here is pinned by any golden. That is only - * sound while no recording can reach it: a mutant table an adapter imported would change what the - * recording loads while every header stayed still. Reachability is proved from the recording - * drivers outward rather than from this directory inward, because the question is what a golden's - * bytes can depend on. The name scan then covers the paths a module can be read by rather than - * imported, under either spelling of the directory. + * A mutant planted on the recording path would be recorded and replayed alike, so every golden + * would compare clean while certifying the mutated code rather than the product. Reachability is + * proved from the recording drivers outward rather than from this directory inward, because the + * question is what a golden's bytes can depend on. The name scan then covers the paths a module can + * be read by rather than imported. */ describe('the mutant seam', () => { const outside = sources(recorder).filter((file) => !file.startsWith(`${mutants}${sep}`)) @@ -80,6 +76,7 @@ describe('the mutant seam', () => { // recording file is reachable today, and one that stops being reachable is an orphan. const missed = outside .filter((file) => !suite(file) && !graph.has(file)) + .filter((file) => !OFF_RECORDING_PATH.includes(relative(file))) .map(relative) .sort() expect(missed).toEqual([]) @@ -88,15 +85,9 @@ describe('the mutant seam', () => { // A test that does not record cannot change a recording; the drivers do record, so they are held // to the engine's rule — a driver that read the table would change what it records silently. - // Both names, because `MUTANT_DIRECTORY` spells the same path without the literal. - it('is named in no recording file but the digest that excludes it', () => { + it('is named in no recording file', () => { const naming = outside - .filter( - (file) => - !file.endsWith(EXCLUDER) && - !suite(file) && - NAMES.some((name) => readFileSync(file, 'utf8').includes(name)) - ) + .filter((file) => !suite(file) && readFileSync(file, 'utf8').includes('mutants')) .map(relative) expect(naming).toEqual([]) }) diff --git a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts index 01422c4dda4..4cc4afbb468 100644 --- a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts +++ b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts @@ -21,7 +21,6 @@ describe('nativeMountingSubstitutes', () => { const store = importDefault(substitute('@react-native-async-storage/async-storage')).default // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the member is a function by construction; the test asserts what calling it throws. const getItem = (store as { getItem: () => unknown }).getItem - expect(typeof getItem).toBe('function') expect(() => getItem()).toThrow( 'Native store reached during recording: @react-native-async-storage/async-storage.getItem' ) diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts index cd97f5ce529..d4aea1cf8bd 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts @@ -129,7 +129,6 @@ describe('the mounted module loader', () => { ` }) const { read } = modules.load<{ read: () => unknown }>('mobile/src/uses-default.ts') - expect(typeof read).toBe('function') expect(() => read()).toThrow( 'Unspecified native mounting dependency: react-native-not-substituted.default' ) @@ -160,7 +159,6 @@ describe('the mounted module loader', () => { ` }) const { read } = modules.load<{ read: () => unknown }>('mobile/src/uses-namespace.ts') - expect(typeof read).toBe('function') expect(() => read()).toThrow( 'Unspecified native mounting dependency: expo-not-substituted.selectionAsync' ) diff --git a/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts b/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts index 3aa0f1b7e46..53a705fba22 100644 --- a/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts @@ -6,13 +6,10 @@ import type { MountOptions } from './mounted-operation-module' import type { MountAdapter } from './recording-scenario' /** - * The mount table one recording runs against: every registered domain module, merged. Nothing is - * mounted here, because an adapter defined in this file would be pinned by `recorderSha256` on - * every golden rather than by `adapterSha256` on the goldens that mount it. + * The mount table one recording runs against: every registered domain module, merged. * * Each module gets its own loader, carrying its own exposures. One recording mounts one adapter, so - * a golden is only ever influenced by the exposures its own module declares — which is what lets - * `adapterSha256` pin them instead of every golden's `recorderSha256`. + * a golden is only ever influenced by the exposures its own module declares. */ export function pilotMountAdapters( root: string, diff --git a/mobile/src/test-support/rpc-recording/pilot-recordings.test.ts b/mobile/src/test-support/rpc-recording/pilot-recordings.test.ts index d3e5c9d12aa..64f91a420fe 100644 --- a/mobile/src/test-support/rpc-recording/pilot-recordings.test.ts +++ b/mobile/src/test-support/rpc-recording/pilot-recordings.test.ts @@ -5,13 +5,7 @@ import { describe, expect, it } from 'vitest' import { runRecording } from './run-recording' import { pilotMountAdapters } from './pilot-mount-adapters' import { vitestRecordingScheduler } from './vitest-recording-scheduler' -import { - compareGolden, - goldenBytes, - goldenRecording, - readGolden, - writeGolden -} from './golden-recording' +import { expectGoldenFile, goldenBytes, goldenRecording, writeGolden } from './golden-recording' import type { Recording } from './recording-scenario' import type { RecordedValue } from './recording-values' import { determinismRuns } from './determinism-runs' @@ -42,7 +36,7 @@ describe('RPC main recordings', () => { expect(visibleState(recording)).toEqual({ files: ['third.ts'] }) } if (id === 'b2') { - // The shipped null result is still the seed, and the screen still reports an error the + // The null result is still the seed, and the screen still reports an error the // user can see. What moved in step 7 is the sentence: the checked reader names the reply // and the method, where main read `.ok` off null and showed V8's property-read text. expect(visibleState(recording)).toMatchObject({ @@ -56,7 +50,7 @@ describe('RPC main recordings', () => { loading: false }) } - const golden = goldenRecording(root, input.baseline, pilot.scenarios(), recording) + const golden = goldenRecording(pilot.scenarios(), recording) const bytes = goldenBytes(golden) if (run) { expect(bytes).toBe(first) @@ -65,7 +59,7 @@ describe('RPC main recordings', () => { if (process.env.RPC_FOUNDATION_MODE === '--record') { await writeGolden(goldens, golden, '--record') } else { - compareGolden(readGolden(goldens, id), golden) + await expectGoldenFile(goldens, id, golden) } } }) diff --git a/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts b/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts new file mode 100644 index 00000000000..97b4dd9e7d1 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts @@ -0,0 +1,173 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + RPC_METHODS_WITHOUT_SHARED_PARAMS, + RPC_PARAMS_BY_METHOD +} from '../../../../src/shared/rpc-contract/rpc-params-catalog.generated' +import { defineMethod } from '../../../../src/main/runtime/rpc/core' +import { parseRpcRequestParams } from '../../../../src/main/runtime/rpc/dispatcher-request-parsing' +import { derivedGoldens } from './derived-goldens' +import { readGolden } from './golden-recording' +import { readScenarios } from './scenario-input' + +/** + * Every request a golden records putting on the wire, parsed by the host's own dispatcher against + * the host's own params schema. The goldens script the host's replies, so a request the real host + * would refuse with `invalid_argument` is recorded as a success unless something checks it here; + * and a desktop change that tightens a schema is invisible to every golden without this. + */ + +const root = resolve(import.meta.dirname, '../../../..') +const manifest = readScenarios( + process.env.RPC_FOUNDATION_SCENARIOS ?? + resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json') +).scenarios +const directory = + process.env.RPC_FOUNDATION_GOLDENS ?? resolve(root, 'mobile/rpc-foundation/goldens') + +/** + * Keys the host's schema strips that the phone sends on purpose, each with its reason. Only an + * older or newer host reading the key justifies an entry; an entry nothing strips fails, so the list + * only shrinks. + */ +const STRIPPED_KEY_INVENTORY: readonly InventoryEntry[] = [] + +type WireRequest = { method: string; params: unknown; golden: string } + +function wireRequests(): WireRequest[] { + const seen = new Map() + for (const { id } of derivedGoldens(manifest)) { + for (const checkpoint of readGolden(directory, id).recording.checkpoints) { + const payloads = checkpoint.observation.payloads + for (const payload of Array.isArray(payloads) ? payloads : []) { + const json = + payload && typeof payload === 'object' && 'json' in payload ? payload.json : undefined + if (typeof json !== 'string') { + continue + } + const frame: unknown = JSON.parse(json) + if (!frame || typeof frame !== 'object' || !('method' in frame)) { + continue + } + const method = String(frame.method) + const params = 'params' in frame ? frame.params : undefined + const key = `${method}\0${JSON.stringify(params)}` + if (!seen.has(key)) { + seen.set(key, { method, params, golden: id }) + } + } + } + } + return [...seen.values()] +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** Paths present in what was sent and absent from what the handler receives. */ +function strippedPaths(sent: unknown, parsed: unknown, path = ''): string[] { + if (Array.isArray(sent) && Array.isArray(parsed)) { + return sent.flatMap((entry, index) => strippedPaths(entry, parsed[index], `${path}[${index}]`)) + } + if (!isPlainObject(sent) || !isPlainObject(parsed)) { + return [] + } + return Object.keys(sent).flatMap((key) => + key in parsed ? strippedPaths(sent[key], parsed[key], `${path}.${key}`) : [`${path}.${key}`] + ) +} + +function isEmptyParams(params: unknown): boolean { + return ( + params === undefined || + params === null || + (isPlainObject(params) && !Object.keys(params).length) + ) +} + +type InventoryEntry = { method: string; path: string; reason: string } + +/** Every way the host would refuse, ignore or trim a request, plus inventory entries nothing uses. */ +function contractProblems( + requests: readonly WireRequest[], + inventory: readonly InventoryEntry[] +): string[] { + const unshared = new Set(RPC_METHODS_WITHOUT_SHARED_PARAMS) + const problems: string[] = [] + const stripped = new Set() + for (const { method, params, golden } of requests) { + const where = `${golden}: ${method} ${JSON.stringify(params)}` + if (unshared.has(method)) { + continue + } + if (!(method in RPC_PARAMS_BY_METHOD)) { + problems.push(`${where}\n the host has no method ${method}`) + continue + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `method in RPC_PARAMS_BY_METHOD` was checked just above. + const schema = RPC_PARAMS_BY_METHOD[method as keyof typeof RPC_PARAMS_BY_METHOD] + if (schema === null) { + // The dispatcher never reads params for these, so anything sent is dropped whole. + if (!isEmptyParams(params)) { + problems.push(`${where}\n ${method} takes no params; the host ignores these`) + } + continue + } + const parsed = parseRpcRequestParams( + { id: 'recorded', authToken: 'recorded', method, params }, + defineMethod({ name: method, params: schema, handler: () => null }), + { runtimeId: 'recorded' } + ) + if (parsed.error) { + const refusal = parsed.error.ok ? '' : parsed.error.error.message + problems.push(`${where}\n the host refuses it: ${refusal}`) + continue + } + for (const path of strippedPaths(params, parsed.value)) { + if (inventory.some((entry) => entry.method === method && entry.path === path)) { + stripped.add(`${method}\0${path}`) + } else { + problems.push(`${where}\n the host drops ${path}`) + } + } + } + const stale = inventory + .filter((entry) => !stripped.has(`${entry.method}\0${entry.path}`)) + .map((entry) => `${entry.method} ${entry.path}: no recorded request sends it any more`) + return [...problems, ...stale] +} + +describe('recorded requests against the host params contract', () => { + it('sends only methods and params the host dispatcher accepts', () => { + const requests = wireRequests() + expect(requests.length).toBeGreaterThan(100) + expect(contractProblems(requests, STRIPPED_KEY_INVENTORY)).toEqual([]) + // Reads and resolves every golden; a loaded CI worker takes longer than the default 5 s. + }, 60_000) + + // The corpus has no instance of these today, so each rule is shown to fire on a made-up request. + it('names an unknown method, a refusal, ignored params, a dropped key and a stale entry', () => { + const request = (method: string, params: unknown): WireRequest => ({ + method, + params, + golden: 'made-up' + }) + const problems = contractProblems( + [ + request('nothing.here', {}), + request('worktree.activate', {}), + request('status.get', { verbose: true }), + request('terminal.close', { terminal: 'term-1', extra: 1 }) + ], + [{ method: 'terminal.close', path: '.gone', reason: 'made up' }] + ) + expect(problems).toEqual([ + expect.stringContaining('the host has no method nothing.here'), + expect.stringContaining('the host refuses it'), + expect.stringContaining('status.get takes no params; the host ignores these'), + expect.stringContaining('the host drops .extra'), + 'terminal.close .gone: no recorded request sends it any more' + ]) + }) +}) diff --git a/mobile/src/test-support/rpc-recording/recorder-digest.ts b/mobile/src/test-support/rpc-recording/recorder-digest.ts deleted file mode 100644 index a4cd986f784..00000000000 --- a/mobile/src/test-support/rpc-recording/recorder-digest.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync, readdirSync } from 'node:fs' -import { join, posix } from 'node:path' -import { RECORDING_DRIVERS } from './recording-drivers' - -export const RECORDER_DIRECTORY = 'mobile/src/test-support/rpc-recording' -/** The per-domain mount adapters. Excluded below and pinned per golden by `adapterSha256` instead. */ -export const ADAPTER_DIRECTORY = `${RECORDER_DIRECTORY}/adapters` -/** - * Mutant evidence. Excluded below and pinned by nothing: no recording ever reads it. Deliberately - * not exported — an importable handle is a way for the recording path to name the directory without - * spelling it, and `mutants/mutant-seam.test.ts` rejects both spellings. - */ -const MUTANT_DIRECTORY = `${RECORDER_DIRECTORY}/mutants` -const digests = new Map() - -function skippedTest(name: string): boolean { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: membership test on a readonly literal tuple, not a cast of the value. - return name.endsWith('.test.ts') && !(RECORDING_DRIVERS as readonly string[]).includes(name) -} - -function collect(root: string, relative: string, files: string[]): void { - for (const entry of readdirSync(join(root, relative), { withFileTypes: true }).sort((a, b) => - a.name < b.name ? -1 : 1 - )) { - const child = `${relative}/${entry.name}` - if (entry.isDirectory()) { - if (child !== ADAPTER_DIRECTORY && child !== MUTANT_DIRECTORY) { - collect(root, child, files) - } - } else if (!entry.name.endsWith('.md') && !skippedTest(entry.name)) { - files.push(child) - } - } -} - -/** - * Every executable recorder input a golden shares with every other golden: the engine, and nothing - * domain-specific. Prose is excluded because it cannot change a recording; a candidate run - * recomputes this and `compareGolden` fails the header, which forces an engine edit to re-record - * deliberately. - * - * A suite that does not record is absent too, for the same reason the mutants are: it cannot put an - * observation in a golden, so pinning it would claim a provenance the golden does not have. - * - * Three further inputs are deliberately absent. The scenario manifest used to be here, which made every - * golden's header a function of every other family's scenarios; the mount adapters used to be here - * too, which made it a function of every other family's adapter. `scenarioSha256` and - * `adapterSha256` pin each golden to its own instead. The mutants are absent for a different - * reason: nothing on the recording path reads them, so no edit there can change a recording, and - * pinning them would claim a provenance the golden does not have. All three cost the same thing - * when they were here — one domain's addition re-digested all 153 files and put a conflict on that - * line in every domain branch in flight. - */ -export function recorderSha256(root: string): string { - const cached = digests.get(root) - if (cached !== undefined) { - return cached - } - const files: string[] = [] - collect(root, RECORDER_DIRECTORY, files) - const digest = createHash('sha256') - .update( - files - .map((file) => `${file}:${readFileSync(join(root, ...file.split(posix.sep)))}`) - .join('\n') - ) - .digest('hex') - digests.set(root, digest) - return digest -} diff --git a/mobile/src/test-support/rpc-recording/recorder-fixture-shape.ts b/mobile/src/test-support/rpc-recording/recorder-fixture-shape.ts index b65c078fafa..efb1e68723b 100644 --- a/mobile/src/test-support/rpc-recording/recorder-fixture-shape.ts +++ b/mobile/src/test-support/rpc-recording/recorder-fixture-shape.ts @@ -41,9 +41,8 @@ export function mountFixture(value: PartialRecorderFixture>): T { /** * What the type above accepts and refuses, as compile errors rather than as a claim. It lives in - * this file rather than its own because `recorderSha256` pins every file in this directory and - * `mutant-seam.test.ts` refuses one no recording driver can reach: a compile fence reaches nothing, - * so on its own it would re-digest every golden while being unable to move one. + * this file rather than its own because `mutant-seam.test.ts` refuses a file no recording driver + * can reach, and a compile fence on its own reaches nothing. * `pnpm --dir mobile typecheck` covers this file and excludes every `.test.ts`, so these cases are * the only thing holding the branches up — without them the type records zero errors either way, * because no fixture in the tree happens to carry a callback, a set or a map. diff --git a/mobile/src/test-support/rpc-recording/recording-drivers.ts b/mobile/src/test-support/rpc-recording/recording-drivers.ts index 65e6733bb98..c78abb8c8cd 100644 --- a/mobile/src/test-support/rpc-recording/recording-drivers.ts +++ b/mobile/src/test-support/rpc-recording/recording-drivers.ts @@ -1,7 +1,6 @@ /** - * The suites that write goldens. A golden's bytes come from one of these and from what it imports, - * so these are the only test files `recorderSha256` pins: a suite that merely reads goldens, or - * writes one to a scratch directory, cannot put an observation in a recorded file. + * The suites that write goldens. A golden's bytes come from one of these and from what it imports; + * a suite that merely reads goldens cannot put an observation in a recorded file. * * `scripts/rpc-recording.mts` records exactly this list, so a suite that is added here and nowhere * else still records, and one added there and not here does not exist. diff --git a/mobile/src/test-support/rpc-recording/recording-runner.test.ts b/mobile/src/test-support/rpc-recording/recording-runner.test.ts index 106c43fab30..eb231a1a3bf 100644 --- a/mobile/src/test-support/rpc-recording/recording-runner.test.ts +++ b/mobile/src/test-support/rpc-recording/recording-runner.test.ts @@ -12,15 +12,14 @@ import { join, resolve } from 'node:path' import { operationModuleLoader } from './operation-module-loader' import { describe, expect, it } from 'vitest' import { captureArguments, captureError, captureValue } from './recording-values' -import { RECORDER_DIRECTORY, recorderSha256 } from './recorder-digest' import { RECORDING_DRIVERS } from './recording-drivers' import { ScriptedRpcTransport } from './scripted-rpc-transport' import { vitestRecordingScheduler } from './vitest-recording-scheduler' import { compareGolden, + expectGoldenFile, GOLDEN_FORMAT_VERSION, goldenBytes, - PROJECTION_VERSION, readGolden, writeGolden, type GoldenRecording @@ -131,34 +130,79 @@ describe('recording boundaries', () => { } }) - it('never writes from candidate mode and requires both recording authorizations', async () => { + it('writes only in record mode and names the command for a missing or stale golden', async () => { const directory = mkdtempSync(join(tmpdir(), 'rpc-recording-')) const golden = sampleGolden('test') - const previous = process.env.RPC_FOUNDATION_RECORD try { - process.env.RPC_FOUNDATION_RECORD = '0' - await expect(writeGolden(directory, golden, '--record')).rejects.toThrow('require') - process.env.RPC_FOUNDATION_RECORD = '1' await expect(writeGolden(directory, golden, 'candidate')).rejects.toThrow('require') await writeGolden(directory, golden, '--record') expect(readGolden(directory, 'test')).toEqual(golden) expect(() => readGolden(directory, '../escape')).toThrow('Unsafe') + expect(() => readGolden(directory, 'absent')).toThrow( + 'No golden recorded for absent. Record it: pnpm --dir mobile rpc:record absent' + ) writeFileSync( join(directory, 'stale.json'), JSON.stringify({ ...JSON.parse(readFileSync(join(directory, 'test.json'), 'utf8')), - goldenFormatVersion: 1 + goldenFormatVersion: 5 }) ) expect(() => readGolden(directory, 'stale')).toThrow( - `format version 1; this reader requires ${GOLDEN_FORMAT_VERSION}` + `format version 5; this reader requires ${GOLDEN_FORMAT_VERSION}` + ) + } finally { + rmSync(directory, { recursive: true }) + } + }) + + it('replays only a file that is exactly what rpc:record writes for the run', async () => { + const directory = mkdtempSync(join(tmpdir(), 'rpc-recording-')) + const golden = sampleGolden('exact') + const path = join(directory, 'exact.json') + try { + await writeGolden(directory, golden, '--record') + const written = readFileSync(path, 'utf8') + await expectGoldenFile(directory, 'exact', golden) + const sameRecording = + /holds the same recording but is not the file rpc:record writes for it[\s\S]*pnpm --dir mobile rpc:record exact/ + // Each edit below decodes to the same recording, so only the file text can catch it. + writeFileSync(path, written.replace('{\n', '{\n "baseline": "abc123",\n')) + await expect(expectGoldenFile(directory, 'exact', golden)).rejects.toThrow(sameRecording) + writeFileSync( + path, + written.replace( + '"goldenFormatVersion": 6,\n "operation": "op",', + '"operation": "op",\n "goldenFormatVersion": 6,' + ) + ) + await expect(expectGoldenFile(directory, 'exact', golden)).rejects.toThrow(sameRecording) + const file = goldenFile(golden) + writeFileSync( + path, + JSON.stringify({ ...file, values: { ...file.values, [valueHash('unread')]: 'unread' } }) + ) + await expect(expectGoldenFile(directory, 'exact', golden)).rejects.toThrow( + /Golden exact: Golden pool holds unreferenced values[\s\S]*pnpm --dir mobile rpc:record exact/ + ) + writeFileSync(path, written.replace('"idle"', '"edited"')) + await expect(expectGoldenFile(directory, 'exact', golden)).rejects.toThrow( + /does not hash to its pool key[\s\S]*pnpm --dir mobile rpc:record exact/ + ) + writeFileSync(path, written.slice(0, -10)) + await expect(expectGoldenFile(directory, 'exact', golden)).rejects.toThrow( + /Golden exact: [\s\S]*pnpm --dir mobile rpc:record exact/ + ) + writeFileSync(path, written) + const moved = sampleGolden('exact') + moved.recording.checkpoints[0]!.observation.state = { phase: 'busy' } + await expect(expectGoldenFile(directory, 'exact', moved)).rejects.toThrow( + /Recording differs: exact\n {2}checkpoint settled field state\.phase/ + ) + await expect(expectGoldenFile(directory, 'absent', sampleGolden('absent'))).rejects.toThrow( + 'No golden recorded for absent' ) } finally { - if (previous === undefined) { - delete process.env.RPC_FOUNDATION_RECORD - } else { - process.env.RPC_FOUNDATION_RECORD = previous - } rmSync(directory, { recursive: true }) } }) @@ -234,27 +278,48 @@ describe('recording boundaries', () => { const actual = sampleGolden('diffable') actual.recording.checkpoints[0]!.observation.state = { phase: 'busy' } expect(() => compareGolden(expected, actual)).toThrow( - /Recording differs: diffable checkpoint settled field state\.phase[\s\S]*"idle"[\s\S]*"busy"/ + /Recording differs: diffable\n {2}checkpoint settled field state\.phase\n[\s\S]*"idle"[\s\S]*"busy"[\s\S]*pnpm --dir mobile rpc:record diffable/ ) }) - it('accepts a dependency bump but still refuses a different baseline', () => { - const expected = sampleGolden('provenance') - const bumped = sampleGolden('provenance') - bumped.lockfileSha256 = 'd'.repeat(64) - expect(() => compareGolden(expected, bumped)).not.toThrow() - const rebased = sampleGolden('provenance') - rebased.baseline = 'e'.repeat(40) - expect(() => compareGolden(expected, rebased)).toThrow( - /Recording differs: provenance header baseline/ + it('reports every difference at once, identity fields by name', () => { + const expected = sampleGolden('several') + expected.recording.checkpoints.push({ id: 'later', observation: observation('idle') }) + const actual = sampleGolden('several') + actual.family = 'moved' + actual.namedDeltas = ['delta'] + actual.recording.checkpoints.push({ id: 'later', observation: observation('done') }) + actual.recording.checkpoints[0]!.observation.effects = [{ name: 'extra' }] + let message = '' + try { + compareGolden(expected, actual) + } catch (error) { + message = String(error) + } + expect(message).toContain('family\n expected "op"\n actual "moved"') + expect(message).toContain('namedDeltas\n expected []\n actual ["delta"]') + expect(message).toContain('checkpoint settled field effects') + expect(message).toContain('checkpoint later field state.phase') + }) + + it('names a checkpoint list that moved, and lets the bytes decide when every field agrees', () => { + const expected = sampleGolden('listed') + const dropped = sampleGolden('listed') + dropped.recording.checkpoints = [] + expect(() => compareGolden(expected, dropped)).toThrow( + /checkpoint list\n {4}no longer recorded: settled/ ) + const reordered = sampleGolden('listed') + reordered.recording.checkpoints[0]!.observation.state = { phase: 'idle', at: 1 } + const keyOrder = sampleGolden('listed') + keyOrder.recording.checkpoints[0]!.observation.state = { at: 1, phase: 'idle' } + expect(() => compareGolden(reordered, keyOrder)).toThrow(/encoding: every field matches/) }) it('records a shared prefix once and refuses a variant that already diverged', () => { const base: RecordingScenario = { id: 'family', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -298,7 +363,6 @@ describe('recording boundaries', () => { const base: RecordingScenario = { id: 'family', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -324,7 +388,6 @@ describe('recording boundaries', () => { const scenario = (reply: unknown): RecordingScenario => ({ id: 'family', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -366,7 +429,6 @@ describe('recording boundaries', () => { { id: 'drift', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -394,42 +456,6 @@ describe('recording boundaries', () => { }) }) - it('digests every executable recorder input and ignores prose', () => { - const root = mkdtempSync(join(tmpdir(), 'rpc-recorder-')) - try { - const directory = join(root, RECORDER_DIRECTORY) - mkdirSync(directory, { recursive: true }) - writeFileSync(join(directory, 'runner.ts'), 'export const runner = 1') - const original = recorderSha256(root) - writeFileSync(join(directory, 'README.md'), 'prose') - // Each call spells the root differently: `recorderSha256` caches per root string, so reusing - // one would assert nothing. - expect(recorderSha256(`${root}/`)).toBe(original) - writeFileSync(join(directory, 'runner.ts'), 'export const runner = 2') - expect(recorderSha256(`${root}//`)).not.toBe(original) - } finally { - rmSync(root, { recursive: true }) - } - }) - - // A suite that only reads goldens cannot put an observation in one, so it is not provenance; the - // drivers are, because a golden's bytes come from them. - it('digests the recording drivers and no other suite', () => { - const root = mkdtempSync(join(tmpdir(), 'rpc-drivers-')) - try { - const directory = join(root, RECORDER_DIRECTORY) - mkdirSync(directory, { recursive: true }) - writeFileSync(join(directory, 'runner.ts'), 'export const runner = 1') - const original = recorderSha256(root) - writeFileSync(join(directory, 'reads-goldens.test.ts'), 'export const suite = 1') - expect(recorderSha256(`${root}/`)).toBe(original) - writeFileSync(join(directory, RECORDING_DRIVERS[0]), 'export const suite = 1') - expect(recorderSha256(`${root}//`)).not.toBe(original) - } finally { - rmSync(root, { recursive: true }) - } - }) - it('keeps every named driver real and unimported by the engine', () => { const directory = resolve(import.meta.dirname) const missing = RECORDING_DRIVERS.filter((driver) => !existsSync(join(directory, driver))) @@ -468,7 +494,6 @@ describe('recording boundaries', () => { { id: 'request-free', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -578,19 +603,10 @@ function observation(phase: string): Observation { function sampleGolden(id: string): GoldenRecording { return { + goldenFormatVersion: GOLDEN_FORMAT_VERSION, operation: 'op', family: 'op', namedDeltas: [], - runnerVersion: 1, - baseline: 'a'.repeat(40), - lockfileSha256: 'b'.repeat(64), - recorderSha256: 'c'.repeat(64), - adapterSha256: 'f'.repeat(64), - scenarioSha256: 'd'.repeat(64), - platform: process.platform, - scenarioVersion: 1, - projectionVersion: PROJECTION_VERSION, - goldenFormatVersion: GOLDEN_FORMAT_VERSION, recording: { scenario: id, checkpoints: [{ id: 'settled', observation: observation('idle') }] } } } diff --git a/mobile/src/test-support/rpc-recording/recording-scenario.ts b/mobile/src/test-support/rpc-recording/recording-scenario.ts index 12613dd271f..14a7122fbfd 100644 --- a/mobile/src/test-support/rpc-recording/recording-scenario.ts +++ b/mobile/src/test-support/rpc-recording/recording-scenario.ts @@ -30,7 +30,6 @@ export type ScenarioStep = export type RecordingScenario = DeclaredDeviceState & { id: string operation: string - version: number family: string sites: string[] schedules: string[] diff --git a/mobile/src/test-support/rpc-recording/reply-matrix-normal-result.ts b/mobile/src/test-support/rpc-recording/reply-matrix-normal-result.ts index c5d59679f2c..61ce0e26bb1 100644 --- a/mobile/src/test-support/rpc-recording/reply-matrix-normal-result.ts +++ b/mobile/src/test-support/rpc-recording/reply-matrix-normal-result.ts @@ -34,7 +34,7 @@ export const REPLY_MATRIX_NORMAL_RESULT_INVENTORY: readonly ReplyMatrixNormalRes { family: 'project-explicit-false', request: 'github.project.updateIssueBySlug#1', - // The b2 seed's only recorded success is a null result — the shipped bug it exists to pin — and + // The b2 seed's only recorded success is a null result — the regression it exists to pin — and // `result-null` is already its own partition, so replaying it would leave the matrix no control. reason: 'the seed records a null result, which the result-null partition already drives', result: { ok: true } diff --git a/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts b/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts index 502925a900b..642498d0c6f 100644 --- a/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts +++ b/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts @@ -54,9 +54,8 @@ import { vitestRecordingScheduler } from './vitest-recording-scheduler' * * The claim it is built to certify is the one C1 needs before a screen moves to the web: a screen * driven through `BridgeRpcClient` observes what it observes on the native client, down to the - * byte. Headers are excluded because they are provenance of the committed recording, not of this - * run. This suite writes nothing, and it is not in `RECORDING_DRIVERS`, so `recorderSha256` does - * not pin it — a suite that cannot put an observation in a recorded file is not provenance for one. + * byte. This suite writes nothing, and it is not in `RECORDING_DRIVERS`: a suite that cannot put + * an observation in a recorded file does not record one. * * It runs by default, in `pnpm test` and so in CI, and `RPC_FOUNDATION_BRIDGE=0` is what skips it * for a local run that does not want the three minutes. Vitest gives the file a worker of its own @@ -268,7 +267,7 @@ async function verdict( const expected = readGolden(directory, id) const fields = run.recording === null ? [] : divergingFields(expected.recording, run.recording) if (run.recording !== null && fields.length === 0) { - // Not redundant with the field walk: this one also pins the encoding and the header. + // Not redundant with the field walk: this one also pins the encoding. compareGolden(expected, { ...expected, recording: run.recording }) identical += 1 record('identical') diff --git a/mobile/src/test-support/rpc-recording/scenario-digest.ts b/mobile/src/test-support/rpc-recording/scenario-digest.ts deleted file mode 100644 index a83ebe32002..00000000000 --- a/mobile/src/test-support/rpc-recording/scenario-digest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createHash } from 'node:crypto' -import { canonicalJson } from './golden-value-pool' -import { captureValue } from './recording-values' -import type { RecordingScenario } from './recording-scenario' - -/** - * The scenario input one golden was recorded from: every scenario the runner consumed for it, in - * order — one manifest scenario for a pilot golden, the generated variants (and any hoisted - * prelude) for a matrix or schedule golden. - * - * Pinned per golden rather than over the whole manifest so a family added for one domain moves only - * its own goldens, while a field edited inside a scenario moves every golden derived from it. The - * variants are hashed rather than the base they came from because they are what `runRecording` - * consumed: a matrix site, its replayed normal result and its partition replies are all visible - * here without the derivation having to be restated. - * - * `captureValue` sorts object keys and tags an explicit-undefined param, which `JSON.stringify` - * would drop and so conflate with an absent one. - */ -export function scenarioSha256(scenarios: readonly RecordingScenario[]): string { - return createHash('sha256') - .update(canonicalJson(captureValue(scenarios))) - .digest('hex') -} diff --git a/mobile/src/test-support/rpc-recording/scenario-input.ts b/mobile/src/test-support/rpc-recording/scenario-input.ts index 22dfeb9a5b9..e226e2166d6 100644 --- a/mobile/src/test-support/rpc-recording/scenario-input.ts +++ b/mobile/src/test-support/rpc-recording/scenario-input.ts @@ -13,19 +13,29 @@ function decode(value: unknown): unknown { } return value } -export function readScenarios(path: string): { baseline: string; scenarios: RecordingScenario[] } { +/** Keys the format dropped. Refused rather than ignored, so a branch cut before the change says so. */ +const RETIRED_MANIFEST_KEYS = ['baseline'] +const RETIRED_SCENARIO_KEYS = ['version'] + +export function readScenarios(path: string): { scenarios: RecordingScenario[] } { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the manifest shape is validated on the next lines. const input = decode(JSON.parse(readFileSync(path, 'utf8'))) as { - baseline: string scenarios: RecordingScenario[] } - if ( - !/^[a-f0-9]{40}$/.test(input.baseline) || - !Array.isArray(input.scenarios) || - !input.scenarios.length - ) { + if (!Array.isArray(input.scenarios) || !input.scenarios.length) { throw new Error('Invalid recording manifest') } + const retired = [ + ...RETIRED_MANIFEST_KEYS.filter((key) => key in input), + ...input.scenarios.flatMap((scenario) => + RETIRED_SCENARIO_KEYS.filter((key) => key in scenario).map((key) => `${scenario.id}.${key}`) + ) + ] + if (retired.length) { + throw new Error( + `The recording manifest no longer carries a pin or scenario versions; remove: ${retired.join(', ')}` + ) + } const ids = input.scenarios.map((scenario) => scenario.id) if (new Set(ids).size !== ids.length) { throw new Error('Duplicate scenario ids') diff --git a/mobile/src/test-support/rpc-recording/scripted-rpc-transport.ts b/mobile/src/test-support/rpc-recording/scripted-rpc-transport.ts index 912570d10e7..6c983a8c029 100644 --- a/mobile/src/test-support/rpc-recording/scripted-rpc-transport.ts +++ b/mobile/src/test-support/rpc-recording/scripted-rpc-transport.ts @@ -25,10 +25,9 @@ type SendRequestArgs = Parameters * substitute for this one: `requests`, `payloads` and the scripted replies are all still observed * here, and a golden recorded through a wrapper is comparable to the one recorded without it. * - * Declared as a function rather than as an import of the thing that uses it. The page bridge lives - * in `mobile/src/mobile-web-shell/`, which is inside the recorder's own fence, so the engine naming - * it would put a product module in `recorderSha256`; `rpc-recording-through-bridge.test.ts` builds - * the pair and hands it in instead. + * Declared as a function rather than as an import of the thing that uses it: the page bridge in + * `mobile/src/mobile-web-shell/` is product code the native recording never runs, so the engine + * does not load it; `rpc-recording-through-bridge.test.ts` builds the pair and hands it in instead. */ export type ScriptedClientWrapper = (client: RpcClient) => RpcClient diff --git a/mobile/src/test-support/rpc-recording/subscription-recording.test.ts b/mobile/src/test-support/rpc-recording/subscription-recording.test.ts index 39924296947..aab2490fd16 100644 --- a/mobile/src/test-support/rpc-recording/subscription-recording.test.ts +++ b/mobile/src/test-support/rpc-recording/subscription-recording.test.ts @@ -68,16 +68,16 @@ describe('subscription recordings', () => { expect(() => transport.frame(`${CLIENT_EVENTS}#2`, null, changed)).toThrow( 'Missing subscription payload' ) - // The host's own end of stream, in the two responses it really sends: the `end` event as a - // streaming frame, then the unary reply the dispatcher sends once the handler returns. The - // second is what closes the stream here, and the registry reports it to the listener as an - // error — so the disposer below has no subscription left and publishes no unsubscribe. + // The host's streamed `end` closes the stream, so a later reply for the id reaches nothing + // and the disposer below has no subscription left to unsubscribe. transport.frame(`${CLIENT_EVENTS}#1`, null, { ok: true, streaming: true, result: { type: 'end' } }) - transport.frame(`${CLIENT_EVENTS}#1`, null, { ok: true }) + expect(() => transport.frame(`${CLIENT_EVENTS}#1`, null, { ok: true })).toThrow( + 'No open stream for frame' + ) expect(() => transport.frame(`${CLIENT_EVENTS}#1`, null, { ok: false, @@ -86,12 +86,7 @@ describe('subscription recordings', () => { ).toThrow('No open stream for frame') dispose() expect(transport.payloads.map((payload) => payload.name)).toEqual([`${CLIENT_EVENTS}#1`]) - expect(events).toEqual([ - readyFrame('sub-1').result, - changed.result, - { type: 'end' }, - { type: 'error', message: 'Streaming request ended before it was ready.', error: undefined } - ]) + expect(events).toEqual([readyFrame('sub-1').result, changed.result, { type: 'end' }]) } finally { transport.dispose() await clock.flush() @@ -112,7 +107,6 @@ describe('subscription recordings', () => { const scenario = (reply: unknown): RecordingScenario => ({ id: 'stream-crash', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -154,7 +148,6 @@ describe('subscription recordings', () => { { id: 'registry-throws-undefined', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -205,7 +198,6 @@ describe('subscription recordings', () => { { id: 'teardown-streams', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], @@ -254,7 +246,6 @@ describe('subscription recordings', () => { const base: RecordingScenario = { id: 'stream', operation: 'op', - version: 1, family: 'op', sites: [], schedules: [], diff --git a/mobile/src/test-support/rpc-recording/unhandled-recording.test.ts b/mobile/src/test-support/rpc-recording/unhandled-recording.test.ts index 6e89846add4..7d01a00cde5 100644 --- a/mobile/src/test-support/rpc-recording/unhandled-recording.test.ts +++ b/mobile/src/test-support/rpc-recording/unhandled-recording.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' +import { runRecording } from './run-recording' import { recordUnhandledRejections } from './unhandled-recording' +import { vitestRecordingScheduler } from './vitest-recording-scheduler' +import type { RecordingScenario } from './recording-scenario' -// No golden records an unhandled rejection any more, so this is the only thing pinning the capture. +// No golden records an unhandled rejection any more, so these are the only things pinning the +// capture: the helper, and its wiring into a recording, where a dropped hookup would pass every +// golden. describe('recordUnhandledRejections', () => { it('records a detached rejection as an effect and restores the prior listeners', async () => { const before = process.rawListeners('unhandledRejection') @@ -24,4 +29,37 @@ describe('recordUnhandledRejections', () => { ]) expect(process.rawListeners('unhandledRejection')).toEqual(before) }) + + it('reaches a checkpoint and the cleanup checkpoint through a real recording', async () => { + const scenario: RecordingScenario = { + id: 'detached', + operation: 'op', + family: 'op', + sites: [], + schedules: [], + steps: [{ action: 'fire', id: 'fire' }, { checkpoint: 'after-fire' }] + } + const recording = await runRecording( + scenario, + () => ({ + action: () => { + void Promise.reject(new TypeError('detached in an action')) + }, + state: () => ({}), + dispose: () => { + void Promise.reject(new Error('detached in cleanup')) + } + }), + vitestRecordingScheduler() + ) + const effects = (id: string) => + recording.checkpoints.find((checkpoint) => checkpoint.id === id)?.observation.effects + expect(effects('after-fire')).toMatchObject([ + { name: 'unhandled-rejection', value: { message: 'detached in an action' } } + ]) + expect(effects('cleanup')).toMatchObject([ + { name: 'unhandled-rejection', value: { message: 'detached in an action' } }, + { name: 'unhandled-rejection', value: { message: 'detached in cleanup' } } + ]) + }) }) diff --git a/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts b/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts index 4857298c3c6..e535e658c91 100644 --- a/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts +++ b/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts @@ -24,7 +24,6 @@ function scenario(id: string, flushFirst: boolean): RecordingScenario { return { id, operation: 'scheduler-determinism', - version: 1, family: 'scheduler-determinism', sites: [], schedules: [], diff --git a/mobile/src/test-support/webview-document-census.ts b/mobile/src/test-support/webview-document-census.ts index eb050fce3e1..73b9ff71e89 100644 --- a/mobile/src/test-support/webview-document-census.ts +++ b/mobile/src/test-support/webview-document-census.ts @@ -17,7 +17,7 @@ import { parseSync } from 'oxc-parser' */ /** Statement kinds that only declare. Anything else at a module's top level is work. */ -export const DECLARATION_KINDS = new Set([ +const DECLARATION_KINDS = new Set([ 'ImportDeclaration', 'ExportNamedDeclaration', 'ExportDefaultDeclaration', @@ -83,7 +83,7 @@ function initialiserRuns(node: unknown): boolean { } /** Whether anything at a module's top level reaches an element, at any depth. */ -export function readsTheDocument(node: unknown): boolean { +function readsTheDocument(node: unknown): boolean { if (Array.isArray(node)) { return node.some(readsTheDocument) } @@ -93,7 +93,7 @@ export function readsTheDocument(node: unknown): boolean { return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value)) } -export function parseModule(name: string, source: string) { +function parseModule(name: string, source: string) { const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' }) if (errors.length > 0) { throw new Error(`[webview-document-census] ${name}.ts did not parse: ${errors[0]?.message}`) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 99a8e31fa4f..23bdd0cc79a 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -644,40 +644,6 @@ describe('useAllHostClients', () => { } }) - it('keeps startup connection fanout constant for a large saved-host list', async () => { - const hosts = Array.from({ length: 1_000 }, (_, index) => ({ - ...HOST, - id: `host-${index}`, - name: `Host ${index}`, - lastConnected: index - })) - const hostIds = hosts.map((host) => host.id) - const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts) - connectMock.mockReturnValue(makeFakeClient('connected')) - loadHostsMock.mockResolvedValue(hosts) - - let renderer: ReactTestRenderer | null = null - function Probe(): null { - useAllHostClients(hostIds, { autoConnectHostIds }) - return null - } - - try { - await act(async () => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - await Promise.resolve() - }) - expect(connectMock).toHaveBeenCalledTimes(3) - expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([ - 'host-999', - 'host-998', - 'host-997' - ]) - } finally { - act(() => renderer?.unmount()) - } - }) - it('closes a demoted Home client when the recent-host set rotates', async () => { const hosts = [ { ...HOST, id: 'host-a', lastConnected: 4 }, diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts index ba2d0e4ae00..0e619e5e29b 100644 --- a/mobile/src/transport/connection-health.test.ts +++ b/mobile/src/transport/connection-health.test.ts @@ -80,12 +80,6 @@ describe('classifyConnection Tailscale hint', () => { expect('hint' in warning && warning.hint).toBeFalsy() }) - it('keeps plain labels when no endpoint is provided', () => { - const verdict = classifyConnection({ ...base, reconnectAttempts: 3 }) - expect(verdict.kind).toBe('warning') - expect('hint' in verdict && verdict.hint).toBeFalsy() - }) - it('never hints on healthy states', () => { const verdict = classifyConnection({ state: 'connected', diff --git a/mobile/src/transport/connection-log-persistence-revisions.test.ts b/mobile/src/transport/connection-log-persistence-revisions.test.ts index abe40775911..cf3f57929b0 100644 --- a/mobile/src/transport/connection-log-persistence-revisions.test.ts +++ b/mobile/src/transport/connection-log-persistence-revisions.test.ts @@ -100,22 +100,4 @@ describe('connection log persistence revisions', () => { expect(save).toHaveBeenCalledTimes(2) expect(save).toHaveBeenLastCalledWith('a', store.get('a')) }) - - it('writes once per snapshot during a sustained failure', async () => { - const save = vi.fn(async () => {}) - const store = createConnectionLogStore(200, { load: async () => [], save }) - await store.hydrate('a') - await drain() - save.mockReset().mockRejectedValue(new Error('unavailable')) - for (let i = 0; i < 3; i++) { - store.append('a', entry(i)) - } - await drain() - expect(save).toHaveBeenCalledTimes(1) - save.mockClear().mockResolvedValue(undefined) - store.append('a', entry(3)) - await drain() - expect(save).toHaveBeenCalledTimes(1) - expect(save).toHaveBeenLastCalledWith('a', store.get('a')) - }) }) diff --git a/mobile/src/transport/foreground-stale-dial-restart.test.ts b/mobile/src/transport/foreground-stale-dial-restart.test.ts index 39410e8fc3f..b7467f6354c 100644 --- a/mobile/src/transport/foreground-stale-dial-restart.test.ts +++ b/mobile/src/transport/foreground-stale-dial-restart.test.ts @@ -115,19 +115,6 @@ function suspend(backgroundMs: number): void { vi.setSystemTime(Date.now() + backgroundMs) } -// Steps the clock one second at a time and reports how long the user has to sit -// on "Connecting…" before the client opens its next socket. -async function millisecondsUntilNextDial(dialsBefore: number): Promise { - const limitMs = 180_000 - for (let elapsedMs = 0; elapsedMs <= limitMs; elapsedMs += 1_000) { - if (sockets.length > dialsBefore) { - return elapsedMs - } - await vi.advanceTimersByTimeAsync(1_000) - } - return limitMs -} - // Waits out the current backoff so the phone is suspended mid-dial — the state // the reporter's phone resumed into — rather than between dials. async function advanceUntilDialing(client: ReturnType): Promise { @@ -181,36 +168,6 @@ describe('foregrounding a phone that was suspended mid-dial', () => { expect(client.getState()).toBe('connected') }) - it('does not strand the user on "Connecting…" for a minute after returning', async () => { - const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key') - latest().authenticate() - latest().close() - - // The desktop drops off the network while the user is still in the app, so - // the tiered backoff climbs into its slow tail (15s, 30s, 60s) before they - // ever leave. This is what makes the post-resume wait a full minute. - for (let dial = 0; dial < 6; dial++) { - await vi.advanceTimersByTimeAsync(12_000 + 60_000) - } - expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(6) - await advanceUntilDialing(client) - - suspend(90_000) - const dialsBefore = sockets.length - client.notifyForeground() - - // The desktop is reachable again and the user is looking at the screen, so - // the redial has to be in flight now — not after the abandoned socket's - // connect budget expires and another tail-length backoff is waited out. - expect(await millisecondsUntilNextDial(dialsBefore)).toBe(0) - - latest().authenticate() - expect(client.getState()).toBe('connected') - expect(label(client, TAILSCALE_ENDPOINT)).toBe('Connected') - - client.close() - }) - it('clears the escalated label once the foreground redial lands', async () => { const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key') latest().authenticate() diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index 146a845a96b..46d905fbdc1 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -111,21 +111,6 @@ describe('host credential cleanup', () => { expect(deleteCredential).toHaveBeenCalledOnce() }) - it('returns after durable intent without waiting for the native delete', async () => { - vi.useFakeTimers() - const deleteCredential = vi.fn(() => new Promise(() => undefined)) - - await expect( - scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000) - ).resolves.toBeUndefined() - await flushMicrotasks() - - expect(storedPendingIds).toEqual(['host-1']) - expect(deleteCredential).toHaveBeenCalledOnce() - - await vi.advanceTimersByTimeAsync(3_000) - }) - it('clears a timed-out pending entry when the native delete later succeeds', async () => { vi.useFakeTimers() let resolveDelete: (() => void) | null = null diff --git a/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts b/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts index d58ebd229e4..642ba1981b8 100644 --- a/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts @@ -200,7 +200,8 @@ describe('mobile relay subscription cancellation', () => { const older = vi.fn() const newer = vi.fn() const cancelOlder = streams.subscribe(method, params, older) - const cancelNewer = streams.subscribe(method, { ...params }, newer) + // A non-slot param differs, which must not split the siblings. + const cancelNewer = streams.subscribe(method, { ...params, limit: 5 }, newer) await Promise.resolve() expect(sendFrame).toHaveBeenCalledTimes(2) cancelOlder() @@ -218,6 +219,84 @@ describe('mobile relay subscription cancellation', () => { } ) + it('sends no terminal unsubscribe when the listener disposes on end', async () => { + const { streams, sendFrame } = createStreams() + const events: unknown[] = [] + let dispose = (): void => {} + dispose = streams.subscribe( + 'terminal.subscribe', + { terminal: 'term', client: { id: 'phone', type: 'mobile' } }, + (event) => { + events.push(event) + // The host already ended it; a slot-named unsubscribe would retire a newer same-slot stream. + dispose() + } + ) + await Promise.resolve() + + expect(streams.handleResponse(response('request-1', { type: 'end' }))).toBe(true) + dispose() + + expect(events).toEqual([{ type: 'end' }]) + expect(sendFrame).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['nativeChat.subscribe', { agent: 'claude', sessionId: 's1', subscriptionId: 'claude:s1' }], + ['terminal.subscribe', { terminal: 'term', client: { id: 'phone' } }] + ])( + 'unsubscribes the newer %s while the evicted older one is still live', + async (method, params) => { + const { streams, sendFrame } = createStreams() + streams.subscribe(method, params, vi.fn()) + const cancelNewer = streams.subscribe(method, { ...params }, vi.fn()) + await Promise.resolve() + expect(sendFrame).toHaveBeenCalledTimes(2) + + // The newer registration evicted the older on the host; the older's end is still in flight. + cancelNewer() + + expect(sendFrame).toHaveBeenCalledTimes(3) + expect(sendFrame).toHaveBeenLastCalledWith( + expect.objectContaining({ method: method.replace(/\.subscribe$/, '.unsubscribe') }) + ) + } + ) + + it('orders same-slot terminal siblings by when they were sent, not when they subscribed', async () => { + const connection = Promise.withResolvers() + let waits = 0 + // The first subscriber waits for the connection; the second is sent at once, so it is older. + const { streams, sendFrame } = createStreams(() => + ++waits === 1 ? connection.promise : Promise.resolve() + ) + const cancelSentLast = streams.subscribe( + 'terminal.subscribe', + { terminal: 'term', client: { id: 'phone' }, viewport: { cols: 40, rows: 20 } }, + vi.fn() + ) + const cancelSentFirst = streams.subscribe( + 'terminal.subscribe', + { terminal: 'term', client: { id: 'phone' }, viewport: { cols: 60, rows: 30 } }, + vi.fn() + ) + await Promise.resolve() + connection.resolve() + await Promise.resolve() + await Promise.resolve() + expect(sendFrame.mock.calls.map(([request]) => request.id)).toEqual(['request-2', 'request-1']) + + cancelSentFirst() + expect(sendFrame).toHaveBeenCalledTimes(2) + cancelSentLast() + expect(sendFrame).toHaveBeenCalledTimes(3) + expect(sendFrame).toHaveBeenLastCalledWith({ + id: 'request-3', + method: 'terminal.unsubscribe', + params: { subscriptionId: 'term:phone', client: { id: 'phone' }, requestId: 'request-1' } + }) + }) + it('keeps skipping for a live newer terminal sibling though each unsubscribe names its request', async () => { const { streams, sendFrame } = createStreams() const params = { terminal: 'term', client: { id: 'phone' } } diff --git a/mobile/src/transport/mobile-relay-rpc-streams.test.ts b/mobile/src/transport/mobile-relay-rpc-streams.test.ts index 78cde925330..d9859d2e041 100644 --- a/mobile/src/transport/mobile-relay-rpc-streams.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-streams.test.ts @@ -38,6 +38,40 @@ describe('MobileRelayRpcStreams failure parity', () => { expect(sendFrame).toHaveBeenCalledTimes(1) }) + it.each([ + [ + 'an error reply', + (streams: MobileRelayRpcStreams) => streams.handleResponse(rpcFailure('stream-1')) + ], + ['a send failure', () => undefined] + ])( + 'sends no terminal unsubscribe when the listener disposes on %s', + async (label, failStream) => { + const sendFrame = vi.fn(() => label !== 'a send failure') + const streams = new MobileRelayRpcStreams({ + nextId: () => 'stream-1', + sendFrame, + waitForConnected: async () => {} + }) + const events: unknown[] = [] + let dispose = (): void => {} + dispose = streams.subscribe( + 'terminal.subscribe', + { terminal: 'term', client: { id: 'phone', type: 'mobile' } }, + (event) => { + events.push(event) + dispose() + } + ) + await Promise.resolve() + failStream(streams) + dispose() + + expect(events).toEqual([expect.objectContaining({ type: 'error' })]) + expect(sendFrame).toHaveBeenCalledTimes(1) + } + ) + it('emits a connection-wait rejection exactly once without sending or cancelling', async () => { const listener = vi.fn() const sendFrame = vi.fn(() => true) diff --git a/mobile/src/transport/mobile-relay-rpc-streams.ts b/mobile/src/transport/mobile-relay-rpc-streams.ts index ad996b3811f..5e4b06c6a22 100644 --- a/mobile/src/transport/mobile-relay-rpc-streams.ts +++ b/mobile/src/transport/mobile-relay-rpc-streams.ts @@ -25,12 +25,26 @@ type StreamRecord = { streamIds: Set subscriptionId?: string cancelled: boolean - sent: boolean + /** Position in this session's send order, or null while unsent. */ + sendOrder: number | null receivedSnapshot?: boolean } type StreamUnsubscribe = { method: string; params: Record } +/** The host cleanup slot an unsubscribe names; the rest of its params never identify a sibling. */ +function unsubscribeSlot({ method, params }: StreamUnsubscribe): string | null { + if (!('subscriptionId' in params)) { + return null + } + const client = + 'client' in params && typeof params.client === 'object' && params.client !== null + ? params.client + : null + const clientId = client && 'id' in client ? client.id : null + return JSON.stringify([method, params.subscriptionId, clientId]) +} + /** Unsubscribe derived from the subscribe params alone (no server-assigned id). */ function buildParamsUnsubscribe( method: string, @@ -59,6 +73,7 @@ export class MobileRelayRpcStreams { private readonly terminalListeners = new Map void>() private readonly terminalSnapshots = new Map() private activeBrowserStream: StreamRecord | null = null + private sendCount = 0 constructor(private readonly options: StreamManagerOptions) {} @@ -76,14 +91,14 @@ export class MobileRelayRpcStreams { onBinaryFrame: subscribeOptions?.onBinaryFrame, streamIds: new Set(), cancelled: false, - sent: false + sendOrder: null } this.streams.set(id, stream) void this.options .waitForConnected() .then(() => { if (!stream.cancelled) { - stream.sent = true + stream.sendOrder = ++this.sendCount if (!this.options.sendFrame({ id, method, params: stream.params })) { this.fail(id, stream, 'Connection interrupted') } @@ -150,8 +165,7 @@ export class MobileRelayRpcStreams { this.activeBrowserStream = stream } if (metadata.type === 'end') { - stream.listener(result) - this.remove(response.id) + this.finish(response.id, stream, result) return true } } @@ -190,11 +204,11 @@ export class MobileRelayRpcStreams { return } stream.cancelled = true - if (stream.sent) { + if (stream.sendOrder !== null) { const byParams = buildParamsUnsubscribe(stream.method, stream.params, id) if (stream.method === 'terminal.subscribe') { if (byParams) { - this.sendUnsubscribe(byParams, id) + this.sendUnsubscribe(byParams, stream.sendOrder, id) } } else { const unsubscribe = stream.subscriptionId @@ -204,7 +218,7 @@ export class MobileRelayRpcStreams { // The host registers cleanup only after resolving the initial snapshot. this.cancelledSubscriptions.set(id, { method: stream.method, unsubscribe: byParams }) } else if (unsubscribe || byParams) { - this.sendUnsubscribe((unsubscribe ?? byParams)!) + this.sendUnsubscribe((unsubscribe ?? byParams)!, stream.sendOrder) } else if ( stream.method === 'browser.screencast' || stream.method === 'runtime.clientEvents.subscribe' @@ -212,20 +226,26 @@ export class MobileRelayRpcStreams { // Keep only the cleanup route while the server assigns its subscription ID. this.cancelledSubscriptions.set(id, { method: stream.method }) } else if (stream.subscriptionId) { - this.sendUnsubscribe({ - method: stream.method.replace(/\.subscribe$/, '.unsubscribe'), - params: { subscriptionId: stream.subscriptionId } - }) + this.sendUnsubscribe( + { + method: stream.method.replace(/\.subscribe$/, '.unsubscribe'), + params: { subscriptionId: stream.subscriptionId } + }, + stream.sendOrder + ) } } } this.remove(id) } - /** Skip the unsubscribe when a live sibling shares the host cleanup token (e.g. nativeChat's - * deterministic `agent:sessionId`), since the host would evict the sibling's registration. */ - private sendUnsubscribe(unsubscribe: StreamUnsubscribe, terminalRequestId?: string): void { - if (this.hasLiveOwner(unsubscribe)) { + /** Skip when a same-slot sibling sent later has already evicted this stream on the host. */ + private sendUnsubscribe( + unsubscribe: StreamUnsubscribe, + sendOrder: number, + terminalRequestId?: string + ): void { + if (this.hasNewerSlotOwner(unsubscribe, sendOrder)) { return } // Why: added after the sibling check; an old host strips it and would evict the live sibling. @@ -235,14 +255,17 @@ export class MobileRelayRpcStreams { this.options.sendFrame({ id: this.options.nextId(), method: unsubscribe.method, params }) } - private hasLiveOwner(unsubscribe: StreamUnsubscribe): boolean { - const token = JSON.stringify(unsubscribe) + private hasNewerSlotOwner(unsubscribe: StreamUnsubscribe, sendOrder: number): boolean { + const slot = unsubscribeSlot(unsubscribe) + if (slot === null) { + return false + } for (const [siblingId, sibling] of this.streams) { - if (sibling.cancelled || !sibling.sent) { + if (sibling.cancelled || sibling.sendOrder === null || sibling.sendOrder < sendOrder) { continue } const siblingUnsubscribe = buildParamsUnsubscribe(sibling.method, sibling.params, siblingId) - if (siblingUnsubscribe && JSON.stringify(siblingUnsubscribe) === token) { + if (siblingUnsubscribe && unsubscribeSlot(siblingUnsubscribe) === slot) { return true } } @@ -268,10 +291,12 @@ export class MobileRelayRpcStreams { if (stream.cancelled || this.streams.get(id) !== stream) { return } - try { - stream.listener({ type: 'error', message, error }) - } finally { - this.remove(id) - } + this.finish(id, stream, { type: 'error', message, error }) + } + + /** Removed first, so the listener's dispose cannot name a host-ended stream. */ + private finish(id: string, stream: StreamRecord, result: unknown): void { + this.remove(id) + stream.listener(result) } } diff --git a/mobile/src/transport/paired-host-writer-census.test.ts b/mobile/src/transport/paired-host-writer-census.test.ts deleted file mode 100644 index aa7ac0c4f56..00000000000 --- a/mobile/src/transport/paired-host-writer-census.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Who may write a whole host profile: pairing, which creates or re-pairs it. - * - * A relay learner (supervisor, credential rotation, direct upgrade) holds a snapshot taken when its - * connection opened, so a full-profile save from it reverts any Edit Host made since. Those write - * through `setRelayRouting`, which takes no profile. - */ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) - -const PAIRING_WRITERS = [ - 'src/transport/mobile-relay-pairing-recovery.ts', - 'src/transport/pre-profile-pairing-coordinator.ts' -] - -// A value import of `savePairedHost` from the host store, at any relative depth. -const IMPORTS_PAIRED_HOST_WRITER = - /import\s*\{[^}]*\bsavePairedHost\b[^}]*\}\s*from\s*['"](?:\.{1,2}\/)+(?:[\w-]+\/)*host-store['"]/ - -function importingFiles(): string[] { - return ['src', 'app'] - .flatMap((directory) => censusSourceFiles(join(MOBILE_DIR, directory))) - .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) - .filter((file) => IMPORTS_PAIRED_HOST_WRITER.test(readFileSync(file, 'utf8'))) - .map((file) => relative(MOBILE_DIR, file)) - .sort() -} - -describe('full host profile writes', () => { - it('are reachable only from pairing', () => { - // Also the presence precondition: both creators must match, or the matcher is broken. - expect(importingFiles()).toEqual(PAIRING_WRITERS) - }) -}) diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts index ce210333618..48c62af478d 100644 --- a/mobile/src/transport/rpc-client-stream-registry.test.ts +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -79,6 +79,69 @@ describe('RpcClientStreamRegistry', () => { ]) }) + it.each([ + ['a streamed end', (id: string) => streamingResponse(id, { type: 'end' })], + [ + 'a final end reply', + (id: string): RpcResponse => ({ + id, + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'runtime-1' } + }) + ], + [ + 'an error reply', + (id: string): RpcResponse => ({ + id, + ok: false, + error: { code: 'runtime_error', message: 'failed' }, + _meta: { runtimeId: 'runtime-1' } + }) + ] + ])('sends no terminal unsubscribe when the listener disposes on %s', (_label, ending) => { + const { registry, sent } = createRegistry() + const events: unknown[] = [] + let dispose = (): void => {} + dispose = registry.subscribe( + 'terminal.subscribe', + { terminal: 'term-1', client: { id: 'phone', type: 'mobile' } }, + (event) => { + events.push(event) + // The session hook disposes synchronously on end/error; a slot-named unsubscribe here + // would retire a newer same-slot stream on the host. + dispose() + } + ) + const request = sent[0]! + + registry.handleResponse(ending(request.id)) + dispose() + + expect(events).toHaveLength(1) + expect(sent).toEqual([request]) + expect(registry.size()).toBe(0) + }) + + it('does not replay a terminal stream the host already ended', () => { + const { registry, sent, setState } = createRegistry() + registry.subscribe( + 'terminal.subscribe', + { terminal: 'term-1', client: { id: 'phone' } }, + () => {} + ) + const request = sent[0]! + registry.handleResponse(streamingResponse(request.id, { type: 'subscribed', streamId: 7 })) + registry.handleResponse(streamingResponse(request.id, { type: 'end' })) + + setState('connecting') + registry.markForReplay() + setState('connected') + registry.replayAfterAuthentication() + + expect(sent).toEqual([request]) + }) + it('unsubscribes a session tabs stream by its own request id', () => { const { registry, sent } = createRegistry() const disposeOlder = registry.subscribe( diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts index 4062e80b49b..097fda34e65 100644 --- a/mobile/src/transport/rpc-client-stream-registry.ts +++ b/mobile/src/transport/rpc-client-stream-registry.ts @@ -3,13 +3,13 @@ import { type BrowserScreencastFrame } from './browser-screencast-protocol' import { - buildStreamUnsubscribe, - buildTerminalUnsubscribeParams, + buildRequestStreamUnsubscribe, updateTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription' import { isStreamingOpenerReply } from './rpc-acceptance-policies' import { + isStreamEndResult, isStreamingSubscriptionReadyResult, isTerminalSubscribedResult } from './rpc-subscription-result-shapes' @@ -70,8 +70,7 @@ export class RpcClientStreamRegistry { if (this.send(id, stream)) { stream.sent = true } else { - this.emitError(stream, 'Connection interrupted') - this.remove(id) + this.finish(id, stream, { type: 'error', message: 'Connection interrupted' }) } } else { console.log('[net] subscribe queued — waiting for connected', { @@ -125,11 +124,8 @@ export class RpcClientStreamRegistry { const stream = this.streams.get(response.id) if (response.ok) { const result = (response as RpcSuccess).result as Record | null - if (stream && result?.type === 'end') { - if (!stream.cancelled) { - stream.listener(result) - } - this.remove(response.id) + if (stream && isStreamEndResult(result)) { + this.finish(response.id, stream, result) return true } if (stream && result?.type === 'scrollback') { @@ -140,12 +136,13 @@ export class RpcClientStreamRegistry { if (!stream) { return false } - this.emitError( - stream, - response.ok ? 'Streaming request ended before it was ready.' : response.error.message, - response.ok ? undefined : response.error - ) - this.remove(response.id) + this.finish(response.id, stream, { + type: 'error', + message: response.ok + ? 'Streaming request ended before it was ready.' + : response.error.message, + error: response.ok ? undefined : response.error + }) return true } @@ -172,6 +169,10 @@ export class RpcClientStreamRegistry { return } const result = response.result + if (isStreamEndResult(result)) { + this.finish(response.id, stream, result) + return + } if (sessionTabsStream.recordSnapshot(stream, result) && stream.cancelled) { this.dispose(response.id) return @@ -216,19 +217,12 @@ export class RpcClientStreamRegistry { this.disposeServerSubscription(id, stream) return } - if (stream?.method === 'terminal.subscribe') { - const params = buildTerminalUnsubscribeParams(stream.params) - if (params) { - // Why: `requestId` names this exact request; hosts that predate it strip it and use the slot. - this.sendRpc('terminal.unsubscribe', { ...params, requestId: id }) - } - } else if (stream && sessionTabsStream.holdUnsubscribe(stream)) { + if (stream && sessionTabsStream.holdUnsubscribe(stream)) { return - } else { - const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params, id) - if (unsubscribe) { - this.sendRpc(unsubscribe.method, unsubscribe.params) - } + } + const unsubscribe = buildRequestStreamUnsubscribe(stream?.method, stream?.params, id) + if (unsubscribe) { + this.sendRpc(unsubscribe.method, unsubscribe.params) } this.remove(id) } @@ -320,9 +314,12 @@ export class RpcClientStreamRegistry { stream.onBinaryFrame?.(frame) } - private emitError(stream: StreamRequest, message: string, error?: unknown): void { - if (!stream.cancelled) { - stream.listener({ type: 'error', message, error }) + /** Removed first, so neither the listener's dispose nor a replay can name a host-ended stream. */ + private finish(id: string, stream: StreamRequest, result: unknown): void { + const notify = !stream.cancelled + this.remove(id) + if (notify) { + stream.listener(result) } } } diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 2bed2ab0458..a5db812be14 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -76,6 +76,22 @@ export function buildStreamUnsubscribe( return null } +/** Direct-connection unsubscribe for the stream opened by `requestId`, or null when none is required. */ +export function buildRequestStreamUnsubscribe( + method: string | undefined, + params: unknown, + requestId: string +): { method: string; params: Record } | null { + if (method !== 'terminal.subscribe') { + return buildStreamUnsubscribe(method, params, requestId) + } + const unsubscribeParams = buildTerminalUnsubscribeParams(params) + // Why: `requestId` names this exact request; hosts that predate it strip it and use the slot. + return unsubscribeParams + ? { method: 'terminal.unsubscribe', params: { ...unsubscribeParams, requestId } } + : null +} + export function buildTerminalUnsubscribeParams( params: unknown ): { subscriptionId: string; client?: { id: string } } | null { diff --git a/mobile/src/transport/rpc-subscription-result-shapes.ts b/mobile/src/transport/rpc-subscription-result-shapes.ts index 4c624658126..fa5970f5b23 100644 --- a/mobile/src/transport/rpc-subscription-result-shapes.ts +++ b/mobile/src/transport/rpc-subscription-result-shapes.ts @@ -20,6 +20,10 @@ export function isStreamingSubscriptionReadyResult( ) } +export function isStreamEndResult(value: unknown): value is { type: 'end' } { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'end' +} + export function isSnapshotResult(value: unknown): value is { type: 'snapshot' } { return typeof value === 'object' && value !== null && 'type' in value && value.type === 'snapshot' } diff --git a/mobile/src/transport/timer-receiver-census.test.ts b/mobile/src/transport/timer-receiver-census.test.ts deleted file mode 100644 index d6cc23bee2f..00000000000 --- a/mobile/src/transport/timer-receiver-census.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const TIMER_GLOBALS = new Set(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']) -const GLOBAL_RECEIVERS = new Set(['global', 'globalThis', 'window']) -const SHARED_DEFAULTS = new Set(['defaultScheduleTimer', 'defaultCancelTimer']) - -// Sites that take their default from timer-scheduler; the census is meaningless if it -// cannot see them, so an empty or misdirected walk fails instead of passing vacuously. -const SHARED_DEFAULT_SITES = [ - 'files/mobile-file-preview-navigation.ts', - 'transport/host-open-retry-scheduler.ts', - 'transport/mobile-endpoint-lifecycle.ts', - 'transport/mobile-endpoint-supervisor-test-fakes.ts', - 'transport/rpc-session-liveness-watchdog.ts' -] - -const PARKING_OPERATORS = new Set([ - ts.SyntaxKind.QuestionQuestionToken, - ts.SyntaxKind.QuestionQuestionEqualsToken, - ts.SyntaxKind.BarBarToken, - ts.SyntaxKind.BarBarEqualsToken -]) - -type Census = { parked: string[]; shared: string[] } - -function productFiles(): string[] { - return censusSourceFiles(SOURCE_ROOT) - .map((path) => relative(SOURCE_ROOT, path).replaceAll('\\', '/')) - .filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) -} - -function timerName(node: ts.Node): string | null { - if (ts.isIdentifier(node) && TIMER_GLOBALS.has(node.text)) { - return node.text - } - if ( - ts.isPropertyAccessExpression(node) && - TIMER_GLOBALS.has(node.name.text) && - ts.isIdentifier(node.expression) && - GLOBAL_RECEIVERS.has(node.expression.text) - ) { - return node.name.text - } - return null -} - -// The receiver is only lost once the function is parked somewhere a later call reaches -// through: a nullish/logical default, an object literal member, or an assignment onto a -// property. A plain local capture stays legal: calling it bare leaves the receiver undefined. -function parkedTimer(node: ts.Node): ts.Node | null { - if (ts.isBinaryExpression(node)) { - const operator = node.operatorToken.kind - const parks = - PARKING_OPERATORS.has(operator) || - (operator === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left)) - return parks ? node.right : null - } - if (ts.isPropertyAssignment(node)) { - return node.initializer - } - if (ts.isShorthandPropertyAssignment(node)) { - return node.name - } - return null -} - -function scanSource(relativePath: string, text: string, census: Census): void { - const sourceFile = ts.createSourceFile(relativePath, text, ts.ScriptTarget.Latest, true) - const visit = (node: ts.Node): void => { - const candidate = parkedTimer(node) - const name = candidate === null ? null : timerName(candidate) - if (candidate !== null && name !== null) { - const line = sourceFile.getLineAndCharacterOfPosition(candidate.getStart(sourceFile)).line + 1 - census.parked.push(`${relativePath}:${line} ${name}`) - } - if (ts.isIdentifier(node) && SHARED_DEFAULTS.has(node.text)) { - census.shared.push(relativePath) - } - ts.forEachChild(node, visit) - } - visit(sourceFile) -} - -function parkedIn(source: string): string[] { - const census: Census = { parked: [], shared: [] } - scanSource('fixture.ts', source, census) - return census.parked -} - -describe('global timer receiver census', () => { - const census: Census = { parked: [], shared: [] } - for (const relativePath of productFiles()) { - scanSource(relativePath, readFileSync(`${SOURCE_ROOT}${relativePath}`, 'utf8'), census) - } - - it('sees the shared receiver-free defaults, so an empty or misdirected walk cannot pass', () => { - expect(census.shared).toEqual(expect.arrayContaining(SHARED_DEFAULT_SITES)) - }) - - it('parks no bare global timer where a later call would supply a non-global receiver', () => { - expect(census.parked).toEqual([]) - }) - - it.each([ - ['a nullish default', 'const schedule = injected ?? setTimeout'], - ['a logical default', 'const schedule = injected || setTimeout'], - ['a nullish assignment default', 'schedule ??= setTimeout'], - ['a logical assignment default', 'schedule ||= setTimeout'], - ['an object literal member', 'const deps = { setTimer: setTimeout }'], - ['a shorthand object member', 'const deps = { setTimeout }'], - ['an assignment onto a property', 'this.setTimer = setTimeout'], - ['a qualified global read', 'const deps = { setTimer: globalThis.setTimeout }'] - ])('flags a global timer parked by %s', (_form, source) => { - expect(parkedIn(source)).toEqual(['fixture.ts:1 setTimeout']) - }) - - it('leaves a plain local capture alone, which a bare call invokes receiver-free', () => { - expect(parkedIn('const schedule = globalThis.setTimeout')).toEqual([]) - }) -}) diff --git a/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts b/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts index f452a7bd564..463ba943cb5 100644 --- a/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts +++ b/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts @@ -128,11 +128,6 @@ describe('unchecked RPC reader boundary', () => { expect(observed.size).toBe(inventory.length) }) - 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 holding an unchecked reader', () => { const listed = new Set(inventory.map((entry) => entry.file)) const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) diff --git a/mobile/src/worktree/home-resume-card.test.ts b/mobile/src/worktree/home-resume-card.test.ts index 85c6cbab8ee..04a02a7331c 100644 --- a/mobile/src/worktree/home-resume-card.test.ts +++ b/mobile/src/worktree/home-resume-card.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isResumeTargetConfirmedMissing, @@ -7,11 +6,6 @@ import { } from './home-resume-card' import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' -const resumeCardSource = readFileSync( - new URL('../home/MobileHomeResumeCard.tsx', import.meta.url), - 'utf8' -) - function worktree(worktreeId: string): HomeWorktreeSummary { return { worktreeId, @@ -121,11 +115,6 @@ describe('home resume card', () => { actionable: true }) }) - - it('renders the home Resume card inert until its host connects', () => { - expect(resumeCardSource).toContain('disabled={!props.card.actionable}') - expect(resumeCardSource).toContain('!props.card.actionable && styles.cardDisabled') - }) }) // Why (F7): the card is drawn from a snapshot that can name a workspace the desktop deleted diff --git a/mobile/src/worktree/workspace-list-picker-options.test.ts b/mobile/src/worktree/workspace-list-picker-options.test.ts deleted file mode 100644 index b9964fc3850..00000000000 --- a/mobile/src/worktree/workspace-list-picker-options.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { WORKSPACE_SORT_OPTIONS } from './workspace-list-picker-options' - -describe('WORKSPACE_SORT_OPTIONS', () => { - it('keeps the persisted sort values stable for desktop compatibility', () => { - expect(WORKSPACE_SORT_OPTIONS.map((option) => option.value)).toEqual([ - 'smart', - 'name', - 'recent', - 'repo', - 'manual' - ]) - }) - - it('keeps the smart sort value while showing the agent activity label', () => { - expect(WORKSPACE_SORT_OPTIONS.find((option) => option.value === 'smart')).toEqual({ - value: 'smart', - label: 'Agent activity', - subtitle: 'Agents that need attention, then recent activity' - }) - }) -}) diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index 34dd8f7cf66..ab63d955f7a 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -53,7 +53,6 @@ src/session/QuickCommandsSheet.test.ts src/session/ai-vault-resume-launch.test.ts src/session/mobile-file-tap-open.test.ts src/session/mobile-native-chat-open-file.test.ts -src/session/mobile-session-route-parity.test.ts src/session/mobile-session-tab-activation.test.ts src/session/mobile-worker-takeover-send-sites.test.ts src/session/pending-terminal-handle-recovery-poll.test.ts diff --git a/mobile/tsconfig.test.json b/mobile/tsconfig.test.json index b6bb998837f..0c1097c1fe8 100644 --- a/mobile/tsconfig.test.json +++ b/mobile/tsconfig.test.json @@ -10,8 +10,8 @@ // are React Native's, where `setTimeout` answers a number rather than a NodeJS.Timeout, so // pulling that graph in reports ~280 errors about the desktop rather than about mobile. Their // own runtime coverage is vitest, which runs them under Node. - "scripts/rpc-recording-pin-guard.test.ts", "src/tasks/agent-launch-mobile-replay.test.ts", + "src/test-support/rpc-recording/recorded-request-params.test.ts", "src/tasks/mobile-agent-launch-architecture.test.ts", "src/transport/mobile-relay-browser-cancel-budget.test.ts" ] diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index b5dcfb98de1..25a99d2c8b9 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -13,8 +13,8 @@ export default defineConfig({ onConsoleLog: (log) => !log.includes('react-test-renderer is deprecated'), // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were // silently never collected, so render-level regressions shipped untested. - // scripts/ too: the CI guards under it (the RPC recording pin) had no runnable test home, - // and a test vitest never collects is not a gate. + // scripts/ too: the CI scripts under it (rpc:diff, the tests-typecheck ratchet) need a runnable + // test home, and a test vitest never collects is not a gate. include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'scripts/**/*.test.ts'] } }) diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index 3dc3e661f6f..62ad176c57d 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -200,6 +200,10 @@ { "file": "src/notifications/desktop-notification-channel.web.ts", "reason": "The page creates no Android notification channel and the native file already returns early off Android, so what this sibling changes is the import of expo-notifications, whose import-time read breaks the document for the reason push-token.web.ts beside it gives." + }, + { + "file": "src/app-update/use-wall-app-update.web.ts", + "reason": "The update checker runs in the app, not the page. The native file hands the compatibility wall the release that check found; this one offers none, so the wall inside the page keeps its store or releases link and the page bundle never carries the checker." } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02422ea3f19..8b5c4b890d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,7 +111,7 @@ overrides: patchedDependencies: i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 '@vscode/windows-process-tree@0.8.0': b4be93859cefb159949d9cf05f97fa91a221dff0f793a6f03d02d78ae5b32035 - '@xterm/addon-image@0.10.0-beta.300': 5fc280019957573eefe8daa2969534d6090ddb29c6b5842b8f22e99dc1ce3146 + '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.300': b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f @@ -331,7 +331,7 @@ importers: version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) '@xterm/addon-image': specifier: 0.10.0-beta.300 - version: 0.10.0-beta.300(patch_hash=5fc280019957573eefe8daa2969534d6090ddb29c6b5842b8f22e99dc1ce3146)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) + version: 0.10.0-beta.300(patch_hash=e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) '@xterm/addon-ligatures': specifier: 0.11.0-beta.300 version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -10744,7 +10744,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393) - '@xterm/addon-image@0.10.0-beta.300(patch_hash=5fc280019957573eefe8daa2969534d6090ddb29c6b5842b8f22e99dc1ce3146)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))': + '@xterm/addon-image@0.10.0-beta.300(patch_hash=e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))': dependencies: '@xterm/xterm': 6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393) diff --git a/src/cli/agent-session-search-format.test.ts b/src/cli/agent-session-search-format.test.ts index d2893e3edbf..8f38929c521 100644 --- a/src/cli/agent-session-search-format.test.ts +++ b/src/cli/agent-session-search-format.test.ts @@ -67,10 +67,6 @@ describe('formatSessionSearchResponse: results', () => { ) }) - it('leaves the [[ ]] match marks exactly as the engine wrote them', () => { - expect(formatSessionSearchResponse(results())).toContain('[[resize]]') - }) - it('omits the host and the resume line a paired host withheld', () => { const { executionHostId: _host, resumeCommand: _resume, ...withheld } = localHit expect(formatSessionSearchResponse(results({ hits: [withheld] }))).toBe( diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index d94b8447de2..8559bd45f69 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -213,10 +213,6 @@ describe('command aliases', () => { expect(findCommandSpec(specs, ['worktree', 'remove'])?.path).toEqual(['worktree', 'rm']) }) - it('resolves each declared alias to the canonical spec', () => { - expect(findCommandSpec(specs, ['worktree', 'delete'])?.path).toEqual(['worktree', 'rm']) - }) - it('returns undefined for a path matching neither canonical nor alias', () => { expect(findCommandSpec(specs, ['worktree', 'destroy'])).toBeUndefined() }) diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 2bdb8ab0afb..d30724d32f1 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -471,29 +471,6 @@ describe('orca cli browser tab profiles', () => { vi.restoreAllMocks() }) - it('lists browser tab profiles', async () => { - queueFixtures( - callMock, - okFixture('req_profiles', { - profiles: [ - { id: 'default', scope: 'default', label: 'Default', partition: 'persist:orca-browser' }, - { - id: 'work', - scope: 'isolated', - label: 'Work', - partition: 'persist:orca-browser-session-work' - } - ] - }) - ) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['tab', 'profile', 'list', '--json'], '/tmp/not-an-orca-worktree') - - expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.profileList') - }) - it('reports an empty browser tab profile list with a friendly message', async () => { queueFixtures(callMock, okFixture('req_profiles', { profiles: [] })) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/cli-command-name-parity.test.ts b/src/cli/cli-command-name-parity.test.ts deleted file mode 100644 index 32bbcc06add..00000000000 --- a/src/cli/cli-command-name-parity.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CLI_COMMAND_NAMES } from '../main/startup/cli-command-names' -import { COMMAND_SPECS } from './specs' - -const specCommandNames = [...new Set(COMMAND_SPECS.map((spec) => spec.path[0]))].sort() - -describe('CLI command-name parity between COMMAND_SPECS and the launch redirect', () => { - it('has commands to compare', () => { - expect(specCommandNames.length).toBeGreaterThan(0) - }) - - it('redirects every top-level CLI command', () => { - const redirected = new Set(CLI_COMMAND_NAMES) - expect(specCommandNames.filter((name) => !redirected.has(name))).toEqual([]) - }) - - it('lists no command that COMMAND_SPECS does not define', () => { - const specNames = new Set(specCommandNames) - expect([...CLI_COMMAND_NAMES].filter((name) => !specNames.has(name))).toEqual([]) - }) - - it('stays sorted and free of duplicates so additions are easy to review', () => { - expect([...CLI_COMMAND_NAMES]).toEqual([...new Set(CLI_COMMAND_NAMES)].sort()) - }) -}) diff --git a/src/cli/codex-command-classification.test.ts b/src/cli/codex-command-classification.test.ts index ce92d15ee65..ec178efa55e 100644 --- a/src/cli/codex-command-classification.test.ts +++ b/src/cli/codex-command-classification.test.ts @@ -38,6 +38,11 @@ describe('shouldUseRendererBackedCodexTerminal', () => { expect(shouldUseRendererBackedCodexTerminal('codex --help')).toBe(false) }) + it('reads --no-daemon as a flag, not the subcommand', () => { + expect(shouldUseRendererBackedCodexTerminal('codex --no-daemon resume --last')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex --no-daemon exec summarize')).toBe(false) + }) + it('ignores non-Codex commands', () => { expect(shouldUseRendererBackedCodexTerminal(undefined)).toBe(false) expect(shouldUseRendererBackedCodexTerminal('claude')).toBe(false) diff --git a/src/cli/codex-command-classification.ts b/src/cli/codex-command-classification.ts index 941b02c24f6..b68efdcb60d 100644 --- a/src/cli/codex-command-classification.ts +++ b/src/cli/codex-command-classification.ts @@ -62,6 +62,7 @@ const CODEX_GLOBAL_BOOLEAN_FLAGS = new Set([ '--dangerously-bypass-approvals-and-sandbox', '--search', '--no-alt-screen', + '--no-daemon', '--help', '-h', '--version', diff --git a/src/cli/command-suggestion.test.ts b/src/cli/command-suggestion.test.ts index 07e74de5e83..4407d754f29 100644 --- a/src/cli/command-suggestion.test.ts +++ b/src/cli/command-suggestion.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { CommandSpec } from './args' -import { levenshtein, suggestCommands, unknownCommandData } from './command-suggestion' +import { suggestCommands, unknownCommandData } from './command-suggestion' const specs: CommandSpec[] = [ { @@ -45,21 +45,6 @@ const specs: CommandSpec[] = [ } ] -describe('levenshtein', () => { - it('returns 0 for identical strings', () => { - expect(levenshtein('rm', 'rm')).toBe(0) - }) - - it('counts single-edit distance', () => { - expect(levenshtein('remov', 'remove')).toBe(1) - }) - - it('handles empty operands', () => { - expect(levenshtein('', 'abc')).toBe(3) - expect(levenshtein('abc', '')).toBe(3) - }) -}) - describe('suggestCommands', () => { it('suggests the closest command for a near-miss verb', () => { expect(suggestCommands(specs, ['worktree', 'remov'])).toContain('worktree rm') diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index e99d3b379aa..dcdbed780ab 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -1,8 +1,6 @@ import { specPaths, type CommandSpec } from './command-spec' import { levenshtein } from '../shared/edit-distance' -export { levenshtein } from '../shared/edit-distance' - // Why: rank the live registry so typo recovery cannot drift from accepted paths. const SUGGESTION_THRESHOLD = 3 diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index d74de65119e..7ad882b1e11 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -32,8 +32,7 @@ function buildRoutes(groups: readonly HandlerGroup[]): Map const ROUTES = buildRoutes(HANDLER_GROUPS) -// Why: exposes only the canonical command keys (not the handler internals) so the -// registry-parity guard can check specs↔handlers without rebuilding the table. +// Why: exposes only the canonical command keys, not the handler internals. export const HANDLER_COMMAND_KEYS: ReadonlySet = new Set(ROUTES.keys()) export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise { diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index 3849f39a7c9..147305d72a5 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -36,27 +36,6 @@ function isHandlerRecord(value: unknown): value is Record { } describe('handler group manifest', () => { - it('lists a loadable group for every entry', async () => { - for (const group of HANDLER_GROUPS) { - const loaded = await group.load() - expect(loaded, `${group.name} resolved to a non-record`).toBeTypeOf('object') - } - }) - - it('matches each group export key-for-key', async () => { - const drift: string[] = [] - for (const group of HANDLER_GROUPS) { - const actual = Object.keys(await group.load()).sort() - const declared = [...group.keys].sort() - if (JSON.stringify(actual) !== JSON.stringify(declared)) { - drift.push( - `${group.name}: manifest ${JSON.stringify(declared)} !== export ${JSON.stringify(actual)}` - ) - } - } - expect(drift).toEqual([]) - }) - it('exposes every declared key as a callable handler', async () => { const notCallable: string[] = [] for (const group of HANDLER_GROUPS) { @@ -70,15 +49,6 @@ describe('handler group manifest', () => { expect(notCallable).toEqual([]) }) - it('reaches every group through dispatch routing', () => { - const routes = buildHandlerRoutes(HANDLER_GROUPS) - const reached = new Set([...routes.values()].map((group) => group.name)) - const unreachable = HANDLER_GROUPS.filter((group) => !reached.has(group.name)).map( - (group) => group.name - ) - expect(unreachable).toEqual([]) - }) - // Why: dropping a group from the manifest silently unregisters its commands — // walk the tree so a new or forgotten handler file fails here, not in prod. // Nested modules are spread into a parent group rather than registered under diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 2d75a096cc4..a027364a50d 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -389,30 +389,6 @@ describe('agent hooks CLI handler', () => { } ) - it('keeps missing new card style off when updating offline settings', async () => { - const existing = getDefaultPersistedState(userDataPath) - delete existing.settings.experimentalNewWorktreeCardStyle - writeDataFile(userDataPath, existing) - - await runAgentHooksOff(userDataPath) - - expect(readDefaultProfileState(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe( - false - ) - }) - - it('preserves an existing explicit new card style opt-in when updating offline settings', async () => { - const existing = getDefaultPersistedState(userDataPath) - existing.settings.experimentalNewWorktreeCardStyle = true - writeDataFile(userDataPath, existing) - - await runAgentHooksOff(userDataPath) - - expect(readDefaultProfileState(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe( - true - ) - }) - it.each(['on', 'off', 'status', 'prepare-codex'])( 'refuses explicit remote selection before local hook command %s', async (command) => { diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 74b4596accb..91fd5ecde06 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -13,6 +13,7 @@ import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../shared/orca-profiles' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings' type AgentHookCommandResult = { @@ -214,11 +215,9 @@ export const AGENT_HOOK_HANDLERS: Record = { } return } - const settings = await readHookSettings(client) await prepareManagedCodexHomeBeforeShellLaunch({ userDataPath: getDefaultUserDataPath(), - hooksEnabled: - settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') + hooksEnabled: isAgentStatusHooksEnabledForAgent(await readHookSettings(client), 'codex') }) }, 'agent hooks status': async ({ json, flags }) => { diff --git a/src/cli/handlers/artifacts.test.ts b/src/cli/handlers/artifacts.test.ts index 77a8337edb1..cc7beb95bfa 100644 --- a/src/cli/handlers/artifacts.test.ts +++ b/src/cli/handlers/artifacts.test.ts @@ -7,11 +7,8 @@ import { ARTIFACT_HANDLERS } from './artifacts' import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts' import { ARTIFACT_SHARING_DISABLED_CODE, - ARTIFACT_SHARING_DISABLED_MESSAGE, ARTIFACT_SHARING_DISABLED_NEXT_STEPS } from '../../shared/artifact-sharing-gate' -import { RuntimeRpcFailureError } from '../runtime-client' -import { reportCliError } from '../format' const item: ArtifactListItem = { artifact: { @@ -196,81 +193,6 @@ describe('artifact CLI handlers', () => { ) }) - it.each(['artifacts share', 'artifacts update'])( - 'surfaces the capability denial from `%s` with actionable next steps', - async (command) => { - const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-')) - await writeFile(join(cwd, 'report.html'), '

Hi

', 'utf8') - const call = vi.fn().mockRejectedValue( - new RuntimeRpcFailureError({ - id: 'request-1', - ok: false, - error: { - code: ARTIFACT_SHARING_DISABLED_CODE, - message: ARTIFACT_SHARING_DISABLED_MESSAGE, - data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] } - }, - _meta: { runtimeId: 'runtime-1' } - }) - ) - const errorLog = vi.spyOn(console, 'error').mockImplementation(() => undefined) - - await expect( - ARTIFACT_HANDLERS[command]!({ - client: { call } as never, - cwd, - flags: new Map([['file', 'report.html']]), - json: false - }) - ).rejects.toMatchObject({ code: ARTIFACT_SHARING_DISABLED_CODE }) - - // The CLI entry point reports the thrown error; assert the rendered text is actionable. - reportCliError( - await ARTIFACT_HANDLERS[command]!({ - client: { call } as never, - cwd, - flags: new Map([['file', 'report.html']]), - json: false - }).catch((error: unknown) => error), - false, - { commandPath: command.split(' ') } - ) - const rendered = String(errorLog.mock.calls.at(-1)?.[0]) - expect(rendered).toContain(ARTIFACT_SHARING_DISABLED_MESSAGE) - expect(rendered).toContain('Settings → Artifacts') - } - ) - - it('reports the denial with a stable code in --json mode', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-')) - await writeFile(join(cwd, 'report.html'), '

Hi

', 'utf8') - const call = vi.fn().mockRejectedValue( - new RuntimeRpcFailureError({ - id: 'request-1', - ok: false, - error: { - code: ARTIFACT_SHARING_DISABLED_CODE, - message: ARTIFACT_SHARING_DISABLED_MESSAGE, - data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] } - }, - _meta: { runtimeId: 'runtime-1' } - }) - ) - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - - const error = await ARTIFACT_HANDLERS['artifacts share']!({ - client: { call } as never, - cwd, - flags: new Map([['file', 'report.html']]), - json: true - }).catch((thrown: unknown) => thrown) - reportCliError(error, true) - - expect(JSON.parse(String(log.mock.calls.at(-1)?.[0])).error).toMatchObject({ - code: ARTIFACT_SHARING_DISABLED_CODE - }) - }) - it.each(['environment', 'pairing-code'])( 'rejects explicit remote selector --%s', async (flag) => { diff --git a/src/cli/handlers/computer-action-validation.test.ts b/src/cli/handlers/computer-action-validation.test.ts index 2ff677a7920..43b82a0146f 100644 --- a/src/cli/handlers/computer-action-validation.test.ts +++ b/src/cli/handlers/computer-action-validation.test.ts @@ -53,14 +53,6 @@ describe('orca computer action CLI validation', () => { expect(process.exitCode).toBe(1) }) - it('rejects actions without an app before resolving a worktree', async () => { - await main(['computer', 'click', '--element-index', '1'], '/tmp/repo/src') - - expect(callMock).not.toHaveBeenCalled() - expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Missing required --app') - expect(process.exitCode).toBe(1) - }) - it('does not mask missing app with action target errors', async () => { await main(['computer', 'click'], '/tmp/repo/src') diff --git a/src/cli/handlers/linear.test.ts b/src/cli/handlers/linear.test.ts index a8c480439cc..3b669a305d9 100644 --- a/src/cli/handlers/linear.test.ts +++ b/src/cli/handlers/linear.test.ts @@ -157,27 +157,6 @@ describe('orca linear CLI handlers', () => { expect(vi.mocked(console.error)).not.toHaveBeenCalled() }) - it('keeps global boolean flags before Linear commands from consuming command tokens', async () => { - queueFixtures(callMock, okFixture('req_linear', issueResult())) - - await main(['--json', 'linear', 'issue', 'ENG-123', '--full'], '/tmp/repo') - - expect(callMock).toHaveBeenCalledWith( - 'linear.issueContext', - expect.objectContaining({ - input: 'ENG-123', - include: expect.objectContaining({ - comments: true, - children: true, - attachments: true, - relations: true, - activity: true - }) - }), - { timeoutMs: 120_000 } - ) - }) - it('passes verified current-context hints without resolving cwd for remote runtimes', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_123' process.env.ORCA_WORKTREE_ID = 'repo::/srv/app' @@ -259,24 +238,6 @@ describe('orca linear CLI handlers', () => { }) }) - it('keeps boolean flags between Linear and search from consuming the subcommand', async () => { - queueFixtures( - callMock, - okFixture('req_search', { - issues: [], - meta: { query: 'auth', workspaceId: undefined, limit: 1, returned: 0, limitReached: false } - }) - ) - - await main(['linear', '--json', 'search', 'auth', '--limit', '1'], '/tmp/repo') - - expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', { - query: 'auth', - limit: 1, - workspaceId: undefined - }) - }) - it('maps status writes to the agent write RPC with an explicit target', async () => { queueFixtures(callMock, okFixture('req_status', statusSetResult())) diff --git a/src/cli/handlers/orchestration-caller-identity-cli.test.ts b/src/cli/handlers/orchestration-caller-identity-cli.test.ts index dc272e4b94b..e18a7242a58 100644 --- a/src/cli/handlers/orchestration-caller-identity-cli.test.ts +++ b/src/cli/handlers/orchestration-caller-identity-cli.test.ts @@ -273,80 +273,6 @@ describe('orchestration task-create caller handle', () => { expect(callMock).toHaveBeenCalledTimes(1) }) - it('propagates runtime unavailability while proving the bound coordinator', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_creator' - callMock.mockRejectedValueOnce( - new RuntimeClientError('runtime_unavailable', 'runtime_unavailable') - ) - - await expect( - invokeTaskCreate(new Map([['spec', 'do work']])) - ).rejects.toMatchObject({ code: 'runtime_unavailable' }) - - expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', { - terminal: 'term_creator' - }) - expect(getTerminalHandleMock).not.toHaveBeenCalled() - expect(callMock).toHaveBeenCalledTimes(1) - }) - - it('propagates runtime unavailability while reminting the bound coordinator', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_stale' - process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator' - stubStaleHandleRemintFailure( - new RuntimeClientError('runtime_unavailable', 'runtime_unavailable') - ) - getTerminalHandleMock.mockResolvedValue('term_wrong_active') - - await expect( - invokeTaskCreate(new Map([['spec', 'do work']])) - ).rejects.toMatchObject({ code: 'runtime_unavailable' }) - - expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', { - terminal: 'term_stale' - }) - expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', { - paneKey: 'tab_creator:leaf_creator' - }) - expect(getTerminalHandleMock).not.toHaveBeenCalled() - expect(callMock).toHaveBeenCalledTimes(2) - }) - - it('propagates unexpected caller pane remint failures for task creation', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_stale' - process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator' - stubStaleHandleRemintFailure(new RuntimeClientError('permission_denied', 'denied')) - getTerminalHandleMock.mockResolvedValue('term_wrong_active') - - await expect( - invokeTaskCreate(new Map([['spec', 'do work']])) - ).rejects.toMatchObject({ - code: 'permission_denied' - }) - - expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.resolveIdentity', { - terminal: 'term_stale' - }) - expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', { - paneKey: 'tab_creator:leaf_creator' - }) - expect(callMock).toHaveBeenCalledTimes(2) - expect(getTerminalHandleMock).not.toHaveBeenCalled() - }) - - it('propagates unexpected env handle validation failures', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_creator' - callMock.mockRejectedValueOnce(new RuntimeClientError('permission_denied', 'denied')) - - await expect( - invokeTaskCreate(new Map([['spec', 'do work']])) - ).rejects.toMatchObject({ - code: 'permission_denied' - }) - - expect(callMock).toHaveBeenCalledTimes(1) - }) - it('remints a stale task creator env handle from the caller pane key', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_stale' process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator' diff --git a/src/cli/handlers/orchestration-check-identity.test.ts b/src/cli/handlers/orchestration-check-identity.test.ts index 26d4ce7b39a..4d9962a3b10 100644 --- a/src/cli/handlers/orchestration-check-identity.test.ts +++ b/src/cli/handlers/orchestration-check-identity.test.ts @@ -94,19 +94,16 @@ describe('orchestration check identity', () => { ) }) - it.each([true, false])( - 'surfaces a stale --terminal refusal instead of an empty inbox (json=%s)', - async (json) => { - callMock.mockRejectedValue( - Object.assign(new Error('Terminal term_gone has no live pane bound to a Run'), { - code: 'stable_pane_required' - }) - ) + it('surfaces a stale --terminal refusal instead of an empty inbox', async () => { + callMock.mockRejectedValue( + Object.assign(new Error('Terminal term_gone has no live pane bound to a Run'), { + code: 'stable_pane_required' + }) + ) - await expect( - invokeCheck(new Map([['terminal', 'term_gone']]), json) - ).rejects.toMatchObject({ code: 'stable_pane_required' }) - expect(printResultMock).not.toHaveBeenCalled() - } - ) + await expect( + invokeCheck(new Map([['terminal', 'term_gone']])) + ).rejects.toMatchObject({ code: 'stable_pane_required' }) + expect(printResultMock).not.toHaveBeenCalled() + }) }) diff --git a/src/cli/handlers/orchestration-legacy-read-only.test.ts b/src/cli/handlers/orchestration-legacy-read-only.test.ts index 05cca1869cd..150084dbbe1 100644 --- a/src/cli/handlers/orchestration-legacy-read-only.test.ts +++ b/src/cli/handlers/orchestration-legacy-read-only.test.ts @@ -120,47 +120,6 @@ describe('legacy orchestration CLI inspection', () => { expect(response.result.formatted).not.toContain('orchestration reply') }) - it.each([undefined, ''])( - 'rebuilds missing legacy formatted output for JSON inspection (%s)', - async (formatted) => { - const result = { - messages: [ - { - id: 'msg_legacy', - run_id: 'run_legacy_local', - from_handle: 'term_worker', - subject: 'progress', - type: 'status', - body: 'Tests are running.', - payload: '{"phase":"testing"}' - } - ], - count: 1, - formatted - } - callMock.mockResolvedValue({ result }) - - await ORCHESTRATION_HANDLERS['orchestration check']({ - flags: new Map([ - ['terminal', 'term_coord'], - ['peek', true], - ['format', true] - ]), - client: { call: callMock }, - cwd: '/repo', - json: true - } as never) - - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: typeof result & { formatted: string } - } - expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]') - expect(response.result.formatted).toContain('Tests are running.') - expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}') - expect(response.result.formatted).not.toContain('orchestration reply') - } - ) - it('keeps reply guidance only for current rows in a mixed formatted batch', async () => { const result = { messages: [ diff --git a/src/cli/handlers/orchestration-module-boundaries.test.ts b/src/cli/handlers/orchestration-module-boundaries.test.ts index b9a58f71ed7..52603f901a8 100644 --- a/src/cli/handlers/orchestration-module-boundaries.test.ts +++ b/src/cli/handlers/orchestration-module-boundaries.test.ts @@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../format', () => ({ printResult: vi.fn() })) import { printResult } from '../format' -import { HANDLER_GROUPS } from '../handler-group-manifest' -import { ORCHESTRATION_HANDLERS } from './orchestration' import { ORCHESTRATION_DISPATCH_HANDLER, ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS @@ -38,11 +36,6 @@ describe('extracted orchestration flag parsing', () => { }) }) -it('composes handlers in the canonical command order', () => { - const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys - expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared) -}) - describe('extracted orchestration worker formatting', () => { it('renders terminal tails without changing line boundaries', () => { expect( diff --git a/src/cli/handlers/orchestration-run-cli.test.ts b/src/cli/handlers/orchestration-run-cli.test.ts index 7159e802490..59a5ee85cf4 100644 --- a/src/cli/handlers/orchestration-run-cli.test.ts +++ b/src/cli/handlers/orchestration-run-cli.test.ts @@ -6,7 +6,6 @@ const getTerminalHandleMock = vi.hoisted(() => vi.fn()) vi.mock('../format', () => ({ printResult: vi.fn() })) vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) -import { printResult } from '../format' import { ORCHESTRATION_HANDLERS } from './orchestration' describe('lightweight Run CLI handlers', () => { @@ -172,46 +171,3 @@ describe('orchestration reset CLI handler', () => { expect(callMock).not.toHaveBeenCalled() }) }) - -describe('orchestration task-list brief output', () => { - it('requests server-side brief and falls back client-side for older runtimes', async () => { - callMock.mockReset().mockResolvedValue({ - result: { - tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }], - count: 1 - } - }) - vi.mocked(printResult).mockClear() - await ORCHESTRATION_HANDLERS['orchestration task-list']({ - flags: new Map([['brief', true]]), - client: { call: callMock }, - json: true - } as never) - expect(callMock).toHaveBeenCalledWith( - 'orchestration.taskList', - expect.objectContaining({ brief: true }) - ) - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { tasks: { spec: string; spec_truncated: boolean }[] } - } - expect(response.result.tasks[0].spec).toHaveLength(160) - expect(response.result.tasks[0].spec_truncated).toBe(true) - }) - - it('passes server-abbreviated rows through untouched', async () => { - const serverTasks = [ - { id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true } - ] - callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } }) - vi.mocked(printResult).mockClear() - await ORCHESTRATION_HANDLERS['orchestration task-list']({ - flags: new Map([['brief', true]]), - client: { call: callMock }, - json: true - } as never) - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { tasks: { spec: string; spec_truncated: boolean }[] } - } - expect(response.result.tasks).toBe(serverTasks) - }) -}) diff --git a/src/cli/handlers/orchestration-timeout-cli.test.ts b/src/cli/handlers/orchestration-timeout-cli.test.ts deleted file mode 100644 index 9f60548a809..00000000000 --- a/src/cli/handlers/orchestration-timeout-cli.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const callMock = vi.fn() -const originalExitCode = process.exitCode -const originalCliCommand = process.env.ORCA_CLI_COMMAND - -vi.mock('../format', () => ({ printResult: vi.fn() })) -vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) - -import { printResult } from '../format' -import { ORCHESTRATION_HANDLERS } from './orchestration' - -describe('orchestration timeout flag validation', () => { - const invalidTimeoutValues: [string, string | boolean][] = [ - ['missing', true], - ['empty', ''], - ['non-numeric', 'not-a-number'], - ['zero', '0'], - ['negative', '-1'] - ] - - beforeEach(() => { - callMock.mockReset() - delete process.env.ORCA_TERMINAL_HANDLE - delete process.env.ORCA_PANE_KEY - process.exitCode = undefined - }) - - afterEach(() => { - process.exitCode = originalExitCode - if (originalCliCommand === undefined) { - delete process.env.ORCA_CLI_COMMAND - } else { - process.env.ORCA_CLI_COMMAND = originalCliCommand - } - vi.restoreAllMocks() - }) - - const invokeCheck = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration check']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - - const invokeAsk = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - - const invokePlainAsk = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: false - } as never) - - it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { - await expect( - invokeCheck( - new Map([ - ['wait', true], - ['timeout-ms', value] - ]) - ) - ).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - }) - - it('passes a parsed check timeout and peek mode into the RPC payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) - await invokeCheck( - new Map([ - ['wait', true], - ['peek', true], - ['timeout-ms', '250'] - ]) - ) - // Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode. - expect(callMock).toHaveBeenCalledWith('orchestration.check', { - terminal: 'term_worker', - terminalPaneKey: undefined, - unread: false, - peek: true, - all: undefined, - types: undefined, - format: undefined, - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - run: undefined, - ack: undefined, - wait: true, - timeoutMs: 250 - }) - }) - - it('filters already-read rows from a peek response for pre-peek runtimes', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [ - { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, - { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } - ], - count: 2, - formatted: 'banners built from all rows' - } - }) - vi.mocked(printResult).mockClear() - await invokeCheck(new Map([['peek', true]])) - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { messages: { id: string }[]; count: number; formatted?: string } - } - expect(response.result.messages.map((message) => message.id)).toEqual(['msg_new']) - expect(response.result.count).toBe(1) - expect(response.result.formatted).toBeUndefined() - }) - - it('rejects combined read modes before calling the runtime', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - await expect( - invokeCheck( - new Map([ - ['unread', true], - ['peek', true] - ]) - ) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: expect.stringContaining('read mode') - }) - expect(callMock).not.toHaveBeenCalled() - }) - - it('warns when a pre-peek runtime returned a full 100-row page', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: `msg_${index}`, - from_handle: 'a', - subject: `s${index}`, - read: index === 0 ? 0 : 1 - })) - callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - await invokeCheck(new Map([['peek', true]])) - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) - errorSpy.mockRestore() - }) - - it('fails --peek --wait against a runtime that returned only read rows', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], - count: 1 - } - }) - await expect( - invokeCheck( - new Map([ - ['peek', true], - ['wait', true] - ]) - ) - ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) - }) - - it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { - await expect( - invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', value] - ]) - ) - ).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - }) - - it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', '123'] - ]) - ) - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: 'term_coord', - run: undefined, - question: 'Proceed?', - resume: undefined, - options: undefined, - timeoutMs: 123, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 5_123, orchestrationCapability: undefined } - ) - }) - - it('passes an ask resume without creating a new question payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - answer: 'yes', - messageId: 'msg_question', - threadId: 'msg_question', - timedOut: false - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - await invokeAsk(new Map([['resume', 'msg_question']])) - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: undefined, - run: undefined, - question: undefined, - resume: 'msg_question', - options: undefined, - timeoutMs: undefined, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 605_000, orchestrationCapability: undefined } - ) - }) - - it('prints the pending message ID and exact capability-bound resume command on timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - process.env.ORCA_CLI_COMMAND = 'orca-dev' - callMock.mockResolvedValue({ - result: { - answer: null, - messageId: 'msg_question', - threadId: 'thread_question', - timedOut: true, - timeoutMs: 30_000 - } - }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await invokePlainAsk( - new Map([ - ['question', 'Proceed?'], - ['dispatch-capability', 'dcap_secret'], - ['timeout-ms', '30000'] - ]) - ) - - expect(errorSpy).toHaveBeenCalledWith( - 'ask timeout after 30000ms; question is still pending (messageId: msg_question). ' + - 'Resume waiting; do not ask again:\n' + - 'orca-dev orchestration ask --from term_worker --dispatch-capability dcap_secret ' + - '--resume msg_question --timeout-ms 30000' - ) - expect(process.exitCode).toBe(1) - }) - - it('rejects ambiguous ask create/resume input before RPC', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - await expect( - invokeAsk( - new Map([ - ['question', 'new'], - ['resume', 'msg_old'] - ]) - ) - ).rejects.toMatchObject({ code: 'invalid_argument' }) - expect(callMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/cli/handlers/orchestration-timeout.test.ts b/src/cli/handlers/orchestration-timeout.test.ts deleted file mode 100644 index 51f0d75098e..00000000000 --- a/src/cli/handlers/orchestration-timeout.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const callMock = vi.fn() -const getTerminalHandleMock = vi.hoisted(() => vi.fn()) -const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE -const originalPaneKey = process.env.ORCA_PANE_KEY - -vi.mock('../format', () => ({ printResult: vi.fn() })) -vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) - -import { printResult } from '../format' -import { ORCHESTRATION_HANDLERS } from './orchestration' - -const invalidTimeoutValues: [string, string | boolean][] = [ - ['missing', true], - ['empty', ''], - ['non-numeric', 'not-a-number'], - ['zero', '0'], - ['negative', '-1'], - ['fractional', '1.5'], - ['rounded fractional', '9007199254740991.1'], - ['unsafe integer', String(Number.MAX_SAFE_INTEGER + 1)] -] - -const invokeCheck = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration check']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - -const invokeAsk = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - -beforeEach(() => { - callMock.mockReset() - getTerminalHandleMock.mockReset() - vi.mocked(printResult).mockReset() - delete process.env.ORCA_TERMINAL_HANDLE - delete process.env.ORCA_PANE_KEY -}) - -afterEach(() => { - if (originalTerminalHandle === undefined) { - delete process.env.ORCA_TERMINAL_HANDLE - } else { - process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle - } - if (originalPaneKey === undefined) { - delete process.env.ORCA_PANE_KEY - } else { - process.env.ORCA_PANE_KEY = originalPaneKey - } - vi.restoreAllMocks() -}) - -describe('orchestration timeout flag validation', () => { - it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { - const flags = new Map([ - ['wait', true], - ['timeout-ms', value] - ]) - - await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - expect(getTerminalHandleMock).not.toHaveBeenCalled() - }) - - it('passes a parsed check timeout and peek mode into the RPC payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) - - await invokeCheck( - new Map([ - ['wait', true], - ['peek', true], - ['timeout-ms', '250'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith('orchestration.check', { - terminal: 'term_worker', - terminalPaneKey: undefined, - unread: false, - peek: true, - all: undefined, - types: undefined, - format: undefined, - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - run: undefined, - ack: undefined, - wait: true, - timeoutMs: 250 - }) - }) - - it('filters already-read rows from a peek response for pre-peek runtimes', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [ - { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, - { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } - ], - count: 2, - formatted: 'banners built from all rows' - } - }) - - await invokeCheck(new Map([['peek', true]])) - - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { messages: { id: string }[]; count: number; formatted?: string } - } - expect(response.result.messages.map((message) => message.id)).toEqual(['msg_new']) - expect(response.result.count).toBe(1) - expect(response.result.formatted).toBeUndefined() - }) - - it('rejects combined read modes before calling the runtime', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - - await expect( - invokeCheck( - new Map([ - ['unread', true], - ['peek', true] - ]) - ) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: expect.stringContaining('read mode') - }) - expect(callMock).not.toHaveBeenCalled() - }) - - it('warns when a pre-peek runtime returned a full 100-row page', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: `msg_${index}`, - from_handle: 'a', - subject: `s${index}`, - read: index === 0 ? 0 : 1 - })) - callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await invokeCheck(new Map([['peek', true]])) - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) - }) - - it('fails --peek --wait against a runtime that returned only read rows', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], - count: 1 - } - }) - - await expect( - invokeCheck( - new Map([ - ['peek', true], - ['wait', true] - ]) - ) - ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) - }) - - it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { - const flags = new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', value] - ]) - - await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - expect(getTerminalHandleMock).not.toHaveBeenCalled() - }) - - it.each([String(2_147_483_647), String(Number.MAX_SAFE_INTEGER)])( - 'clamps a safe ask timeout %s before adding transport headroom', - async (rawTimeout) => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', rawTimeout] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ timeoutMs: 1_800_000 }), - { timeoutMs: 1_805_000 } - ) - } - ) - - it.each(['+1000', '1000.0', '1e3', '0x3e8'])( - 'preserves CLI-compatible exact integer timeout syntax %s', - async (rawTimeout) => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', rawTimeout] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ timeoutMs: 1_000 }), - { timeoutMs: 6_000 } - ) - } - ) - - it('keeps an omitted ask timeout out of the payload while using default headroom', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ timeoutMs: undefined }), - { timeoutMs: 605_000 } - ) - }) - - it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', '123'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: 'term_coord', - run: undefined, - question: 'Proceed?', - resume: undefined, - options: undefined, - timeoutMs: 123, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 5_123, orchestrationCapability: undefined } - ) - }) -}) diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index b9b13d30304..24d1edf75a4 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -104,34 +104,6 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) - it.each(['succeeded', 'failed'])( - 'accepts a successful start whose task already %s', - async (workerOutcome) => { - const receipt = { - taskId: 'task_1', - dispatchId: 'ctx_1', - state: 'ready', - stage: 'settled', - workerOutcome, - effects: [], - residualResources: [] - } - callMock.mockResolvedValue({ result: receipt }) - await invokeWorkerStart( - new Map([ - ['task', 'task_1'], - ['from', 'term_coord'] - ]) - ) - expect(process.exitCode).toBeUndefined() - expect(printResult).toHaveBeenCalledWith( - expect.objectContaining({ result: receipt }), - true, - expect.any(Function) - ) - } - ) - it('capability-gates and forwards per-invocation launch preferences', async () => { callMock .mockResolvedValueOnce({ diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index e0a20541f91..e285f431ad4 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -7,6 +7,8 @@ const originalPaneKey = process.env.ORCA_PANE_KEY // Why: a structured-session marker inherited from the runner diverts these cases to the // structured refusal, so which branch they exercise would depend on who ran them. const originalStructuredSession = process.env.ORCA_STRUCTURED_SESSION +const originalCliCommand = process.env.ORCA_CLI_COMMAND +const originalExitCode = process.exitCode function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages belong to one exact Dispatch and cannot target a group address.` } @@ -36,6 +38,13 @@ afterEach(() => { } else { process.env.ORCA_STRUCTURED_SESSION = originalStructuredSession } + if (originalCliCommand === undefined) { + delete process.env.ORCA_CLI_COMMAND + } else { + process.env.ORCA_CLI_COMMAND = originalCliCommand + } + process.exitCode = originalExitCode + vi.restoreAllMocks() }) describe('orchestration send structured payload flags', () => { @@ -352,7 +361,10 @@ describe('orchestration timeout flag validation', () => { ['empty', ''], ['non-numeric', 'not-a-number'], ['zero', '0'], - ['negative', '-1'] + ['negative', '-1'], + ['fractional', '1.5'], + ['rounded fractional', '9007199254740991.1'], + ['unsafe integer', String(Number.MAX_SAFE_INTEGER + 1)] ] beforeEach(() => { @@ -360,6 +372,7 @@ describe('orchestration timeout flag validation', () => { delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY delete process.env.ORCA_STRUCTURED_SESSION + process.exitCode = undefined }) const invokeCheck = (flags: Map) => @@ -378,6 +391,14 @@ describe('orchestration timeout flag validation', () => { json: true } as never) + const invokePlainAsk = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration ask']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { const flags = new Map([ ['wait', true], @@ -386,6 +407,7 @@ describe('orchestration timeout flag validation', () => { await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() }) it('passes a parsed check timeout and peek mode into the RPC payload', async () => { @@ -507,6 +529,84 @@ describe('orchestration timeout flag validation', () => { await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it.each([String(Number.MAX_SAFE_INTEGER)])( + 'clamps a safe ask timeout %s before adding transport headroom', + async (rawTimeout) => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', rawTimeout] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ timeoutMs: 1_800_000 }), + { timeoutMs: 1_805_000 } + ) + } + ) + + it('keeps an omitted ask timeout out of the payload while using default headroom', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ timeoutMs: undefined }), + { timeoutMs: 605_000 } + ) + }) + + it('prints the pending message ID and exact capability-bound resume command on timeout', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + process.env.ORCA_CLI_COMMAND = 'orca-dev' + callMock.mockResolvedValue({ + result: { + answer: null, + messageId: 'msg_question', + threadId: 'thread_question', + timedOut: true, + timeoutMs: 30_000 + } + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await invokePlainAsk( + new Map([ + ['question', 'Proceed?'], + ['dispatch-capability', 'dcap_secret'], + ['timeout-ms', '30000'] + ]) + ) + + expect(errorSpy).toHaveBeenCalledWith( + 'ask timeout after 30000ms; question is still pending (messageId: msg_question). ' + + 'Resume waiting; do not ask again:\n' + + 'orca-dev orchestration ask --from term_worker --dispatch-capability dcap_secret ' + + '--resume msg_question --timeout-ms 30000' + ) + expect(process.exitCode).toBe(1) }) it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index f9ec4c6583f..44c8f96d818 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -2,7 +2,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { callOrchestrationMutation } from './mutation-request' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { resolveCoordinatorTerminalHandle, runScopedSessionCaller } from './terminal-identity' export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-create': async ({ flags, client, cwd, json }) => { @@ -37,7 +37,9 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const from = run + ? runScopedSessionCaller(flags) + : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts index f79a846328b..aa9bbaff47e 100644 --- a/src/cli/handlers/orchestration/message-send-handler.ts +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -97,7 +97,10 @@ export const ORCHESTRATION_SEND_HANDLER: Record = { payload: getOptionalStructuredMessagePayload(flags), // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. // A session names itself by its id alone. - senderPaneKey: from === undefined ? undefined : process.env.ORCA_PANE_KEY || undefined, + senderPaneKey: + from === undefined || readInjectedAgentSessionId() + ? undefined + : process.env.ORCA_PANE_KEY || undefined, waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, devMode: isDevCliInvocation() } diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index c4c943261e8..d6201087792 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -4,7 +4,7 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' import { callOrchestrationMutation } from './mutation-request' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { resolveCoordinatorTerminalHandle, runScopedSessionCaller } from './terminal-identity' const TASK_STATUS_VALUES = [ 'pending', @@ -39,7 +39,7 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') const callerTerminalHandle = run - ? undefined + ? runScopedSessionCaller(flags) : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index b97500db89b..8cc4f3357e0 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -7,11 +7,12 @@ import { injectedSessionAddress, readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' +import { sessionAddressForHost } from '../../session-caller-flags' /** * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: * the orchestration envelope carries that id and the host binds the caller param to it, so nothing - * is resolved or guessed here. + * is resolved or guessed here, except a session address only the host can place. */ export async function resolveOrchestrationTerminalHandle( flags: Map, @@ -21,10 +22,11 @@ export async function resolveOrchestrationTerminalHandle( options: { validateEnvHandle?: boolean } = {} ): Promise { // A caller flag naming anyone else was already refused at the CLI entry, from the command's spec. - if (readInjectedAgentSessionId()) { - return undefined - } const explicit = getOptionalStringFlag(flags, flagName) + const sessionId = readInjectedAgentSessionId() + if (sessionId) { + return explicit ? sessionAddressForHost(explicit, sessionId) : undefined + } if (explicit) { return explicit } @@ -175,6 +177,16 @@ export function orchestrationCallerLabel(handle: string | undefined): string { return handle ?? injectedSessionAddress() ?? 'unknown' } +/** + * The caller for a read that `--run` scopes: no terminal is resolved, but a session's declared + * address still goes to the host, which places a `/clear` root and refuses anyone else. + */ +export function runScopedSessionCaller(flags: Map): string | undefined { + const declared = getOptionalStringFlag(flags, 'from') + const sessionId = readInjectedAgentSessionId() + return declared && sessionId ? sessionAddressForHost(declared, sessionId) : undefined +} + export async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, diff --git a/src/cli/handlers/orchestration/worker-output.ts b/src/cli/handlers/orchestration/worker-output.ts index d9b5b9a46fa..9d6d1949911 100644 --- a/src/cli/handlers/orchestration/worker-output.ts +++ b/src/cli/handlers/orchestration/worker-output.ts @@ -1,6 +1,6 @@ import type { RuntimeTerminalRead } from '../../../shared/runtime-types' import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output' -import { formatWorkerTranscriptMessages } from '../../../shared/worker-transcript-text' +import { formatWorkerTranscriptMessage } from '../../../shared/worker-transcript-text' export type LegacyWorkerReadResult = { dispatchId: string @@ -57,7 +57,7 @@ export function formatWorkerRead( const output = value.source === 'terminal' ? value.terminal.tail.join('\n') - : formatWorkerTranscriptMessages(value.transcript.messages).join('\n\n') + : value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') if (output) { return `${details}\n\n${output}` } diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 9922094a8c5..402b9d689e8 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -3,7 +3,8 @@ * `ORCA_AGENT_SESSION_ID` names the caller, and nothing resolves or guesses a terminal for it. * * One rule for every verb that names a caller: a caller flag may restate the session, but a flag - * naming anyone else is refused before any request — never silently dropped, never allowed to win. + * naming anyone else is refused — never silently dropped, never allowed to win. A session address + * the CLI cannot place is left to the host, whose `/clear` lineage decides. * The #21097 accident was a chat that named a sibling's terminal and consumed that sibling's mail. * * The session env here is the hardest case, a chat that inherited a pane's `ORCA_TERMINAL_HANDLE` @@ -29,6 +30,8 @@ import { formatCliError, reportCliError } from './cli-error' import { RuntimeRpcFailureError } from './runtime/types' const SESSION = 'f7a1c0de-1111-4222-8333-444455556666' +/** The session a `/clear` continued as SESSION: the chat's orchestration address stays this one's. */ +const ROOT = '0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' const IDENTITY_ENV = [ 'ORCA_AGENT_SESSION_ID', 'ORCA_TERMINAL_HANDLE', @@ -316,6 +319,25 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v ) }) +describe.each(CALLER_VERBS.filter((verb) => verb.callerFlag !== undefined))( + 'orchestration $command run by a /clear-ed chat', + (verb) => { + beforeEach(asSessionWithInheritedPane) + + it('sends the address it had before the clear for the host to place, instead of refusing it', async () => { + // The chat's address is its lineage root's, which only the host's session records know. + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: `session:${ROOT}` }) + await invoke(verb.command, flags) + + const [params] = callsTo(verb.method) + expect(params?.[verb.callerParam]).toBe(`session:${ROOT}`) + expect(params?.terminalPaneKey).toBeUndefined() + expect(params?.senderPaneKey).toBeUndefined() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + } +) + describe.each([ { command: 'gate-list', method: 'gateList', callerParam: 'from' }, { command: 'task-list', method: 'taskList', callerParam: 'callerTerminalHandle' } @@ -338,6 +360,16 @@ describe.each([ await invoke(command, flagMap({ run: 'run_1', from: `session:${SESSION}` })) expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() }) + + it.each([`session:${ROOT}`, 'session:9d4c1b2a-3e5f-4a6b-8c7d-0e1f2a3b4c5d'])( + 'sends a session address it cannot place (%s) for the host to check, never dropping it', + async (declared) => { + // A /clear-ed chat's root is accepted by the host; anyone else is refused there, before any + // effect. Dropped here, a conflicting caller would pass unchecked. + await invoke(command, flagMap({ run: 'run_1', from: declared })) + expect(callsTo(method)[0]).toMatchObject({ run: 'run_1', [callerParam]: declared }) + } + ) }) describe('the identity a session presents', () => { diff --git a/src/cli/registry-parity.test.ts b/src/cli/registry-parity.test.ts deleted file mode 100644 index 588a4e14d08..00000000000 --- a/src/cli/registry-parity.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { CommandSpec } from './args' -import { HANDLER_COMMAND_KEYS } from './dispatch' -import { findRegistryParityGaps } from './registry-parity' -import { COMMAND_SPECS } from './specs' - -describe('registry parity (live tree)', () => { - const handlerKeys = [...HANDLER_COMMAND_KEYS] - - it('has a handler for every canonical spec path', () => { - const { specsWithoutHandler } = findRegistryParityGaps(COMMAND_SPECS, handlerKeys) - expect(specsWithoutHandler).toEqual([]) - }) - - it('has a spec for every handler key', () => { - const { handlersWithoutSpec } = findRegistryParityGaps(COMMAND_SPECS, handlerKeys) - expect(handlersWithoutSpec).toEqual([]) - }) -}) - -describe('findRegistryParityGaps (fixtures)', () => { - const spec = (path: string[], aliases?: string[][]): CommandSpec => ({ - path, - aliases, - summary: 's', - usage: 'u', - allowedFlags: [] - }) - - it('flags a spec with no handler', () => { - const gaps = findRegistryParityGaps([spec(['foo', 'bar'])], []) - expect(gaps.specsWithoutHandler).toEqual(['foo bar']) - }) - - it('flags a handler with no spec', () => { - const gaps = findRegistryParityGaps([spec(['foo', 'bar'])], ['foo bar', 'ghost cmd']) - expect(gaps.handlersWithoutSpec).toEqual(['ghost cmd']) - }) - - it('does not require a handler for an alias path', () => { - const gaps = findRegistryParityGaps([spec(['foo', 'rm'], [['foo', 'remove']])], ['foo rm']) - expect(gaps.specsWithoutHandler).toEqual([]) - expect(gaps.handlersWithoutSpec).toEqual([]) - }) -}) diff --git a/src/cli/registry-parity.ts b/src/cli/registry-parity.ts deleted file mode 100644 index 27c0130b555..00000000000 --- a/src/cli/registry-parity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CommandSpec } from './args' - -// Why: validation/help and dispatch use parallel registries that must not drift. - -export type RegistryParityGaps = { - handlersWithoutSpec: string[] - specsWithoutHandler: string[] -} - -export function findRegistryParityGaps( - specs: CommandSpec[], - handlerKeys: Iterable -): RegistryParityGaps { - // Why: aliases resolve before dispatch and deliberately have no handler key. - const canonical = new Set(specs.map((spec) => spec.path.join(' '))) - const handlers = new Set(handlerKeys) - return { - handlersWithoutSpec: [...handlers].filter((key) => !canonical.has(key)), - specsWithoutHandler: [...canonical].filter((key) => !handlers.has(key)) - } -} diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index 1487e2364f7..7dc29179065 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -60,8 +60,6 @@ vi.mock('./runtime-client', () => { import { main } from './index' import * as dispatchModule from './dispatch' -const CLI_DIR = __dirname - describe('RuntimeClient module-graph deferral', () => { let logSpy: ReturnType let errorSpy: ReturnType @@ -84,39 +82,6 @@ describe('RuntimeClient module-graph deferral', () => { process.exitCode = 0 }) - // Why: the whole point of the change. These modules load on EVERY - // invocation, so a value-import of the barrel from any of them drags the - // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path. - it.each([ - 'args.ts', - 'flags.ts', - 'dispatch.ts', - 'format.ts', - 'cli-error.ts', - 'selectors.ts', - 'execution-host-flag.ts' - ])('%s imports error classes from ./runtime/types, not the barrel', (file) => { - const source = readFileSync(join(CLI_DIR, file), 'utf8') - const valueImports = source - .split('\n') - .filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'")) - for (const line of valueImports) { - expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /) - } - // Why: format.ts re-exports its error formatters; the guarded import lives in cli-error.ts. - if (file !== 'format.ts') { - expect(source).toContain("} from './runtime/types'") - } - }) - - it('index.ts has no eager value-import of the runtime client', () => { - const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8') - expect(source).toContain("import type { RuntimeClient } from './runtime-client'") - expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m) - expect(source).toContain("await import('./runtime-client.js')") - expect(source).toContain("import { reportCliError } from './cli-error'") - }) - it('constructs no client for --help', async () => { await main(['--help'], '/tmp/repo') expect(constructorArgsMock).not.toHaveBeenCalled() diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index a4964e1a824..ef0c040524b 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -1,8 +1,6 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { normalizeServeModeArgv } from '../main/startup/serve-mode-argv' -import { BOOLEAN_FLAGS, GLOBAL_FLAGS } from './args' +import { BOOLEAN_FLAGS } from './args' import { SERVE_COMMAND_SPECS } from './specs/serve' // Why this test lives under src/cli: the Electron rewrite duplicates the CLI's serve flag list because @@ -51,31 +49,4 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', // Idempotent: `orca serve` spawns the app already in this shape, and the rewrite runs over it too. expect(normalizeServeModeArgv(expected)).toEqual(expected) }) - - it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { - // Why source text: serveOrcaApp spawns a real process; keeping both names visible here makes - // the rewrite/parser contract fail loudly if either side drifts. - const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const serveOptionsSource = readFileSync( - join(process.cwd(), 'src/main/startup/serve-options.ts'), - 'utf8' - ) - const start = serveOptionsSource.indexOf('export function getServeOptions(') - // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. - expect(start).toBeGreaterThanOrEqual(0) - const end = serveOptionsSource.indexOf('\n}', start) - expect(end).toBeGreaterThan(start) - const getServeOptionsBody = serveOptionsSource.slice(start, end) - - for (const flag of translatedFlags) { - expect(launchSource).toContain(`'--serve-${flag}'`) - expect(getServeOptionsBody).toContain(`'--serve-${flag}'`) - } - }) - - it('keeps the untranslated allowlist tied to real global flags', () => { - for (const flag of UNTRANSLATED_GLOBAL_FLAGS) { - expect(GLOBAL_FLAGS).toContain(flag) - } - }) }) diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index cbefa9ee2b8..80078ffda7b 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -1,11 +1,14 @@ /** * An agent session is its own orchestration caller. A flag that names the caller may restate that - * session, but one naming anyone else is refused before any handler runs — never dropped, never - * allowed to win. Against a host that predates session callers this is the only guard: such a host + * session, but one naming anyone else is refused before any effect — never dropped, never allowed + * to win. Against a host that predates session callers this is the only guard: such a host * would honor the flag as the caller, which is how a chat consumed a sibling's mail (#21097). * * Whether `--from`/`--terminal` names the caller or a target is declared on the command's spec * (`identityFlagRoles`) and checked once, here, at the CLI entry, so a handler cannot forget it. + * + * A `/clear`ed chat keeps its lineage root's address, which only the host's records can place, so + * a session address that is not this session's own spelling goes to the host's canonical-id check. */ import type { CommandSpec, IdentityFlag } from './command-spec' @@ -30,13 +33,14 @@ export function refuseConflictingSessionCallerFlags( if ( spec.identityFlagRoles[flagName] === 'caller' && typeof declared === 'string' && - !namesInjectedSession(declared, sessionId, env) + !namesInjectedSession(declared, sessionId, env) && + !sessionAddressForHost(declared, sessionId, env) ) { throw new RuntimeClientError( 'consumer_fenced', `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + - `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + - `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}. No request was sent.` + `different caller. Drop --${flagName}: this session's orchestration commands already act ` + + `as this session. No request was sent.` ) } } @@ -55,3 +59,15 @@ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.Proc value === injectedSessionAddress(env) ) } + +/** A session address only the host can place: this session's `/clear` root, or someone else. */ +export function sessionAddressForHost( + value: string, + sessionId: string, + env: NodeJS.ProcessEnv = process.env +): string | undefined { + return value.startsWith(ORCA_SESSION_ADDRESS_PREFIX) && + !namesInjectedSession(value, sessionId, env) + ? value + : undefined +} diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 536831230b4..bfbd9b420e2 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -104,11 +104,22 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { - "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + # Why local: zsh's warn_create_global warns for each global a function creates. + local __orca_codex_arg __orca_codex_isolate="${ORCA_CODEX_ISOLATE:-1}" + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then + "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null /dev/null) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file +if test "$__orca_codex_type" = file function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT" + command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + end + if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '^(agents|queue|--no-daemon|--remote|--remote=.*)$' $argv; and command codex --help 2>/dev/null /dev/null 2>&1 || : - command codex "$@" - } - fi - unset __orca_codex_binary fi + # Why: a typed alias expands inside the shell, after pane launch prep. + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" + if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { + # Why local: zsh's warn_create_global warns for each global a function creates. + local __orca_codex_arg __orca_codex_isolate="${ORCA_CODEX_ISOLATE:-1}" + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then + "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null /dev/null 2>&1 || : + # Why local: zsh's warn_create_global warns for each global a function creates. + local __orca_codex_arg __orca_codex_isolate="${ORCA_CODEX_ISOLATE:-1}" + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then + "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null /dev/null) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file +if test "$__orca_codex_type" = file function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT" + command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + end + if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '^(agents|queue|--no-daemon|--remote|--remote=.*)$' $argv; and command codex --help 2>/dev/null /dev/null 2>&1 || : - command codex "$@" - } - fi - unset __orca_codex_binary fi + # Why: a typed alias expands inside the shell, after pane launch prep. + # Why unalias inside the substitution: an alias named codex makes command -v + # report the alias text, and the subshell leaves the user's own alias intact. + # Why || : twice — zsh alone aborts inside the substitution, but every shell's + # assignment adopts its exit status, so an absent codex trips set -e in bash too. + __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" + if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + # Why the function reserved word: it suppresses alias expansion of the name, + # which otherwise rewrites this header at parse time and aborts the whole file. + function codex { + # Why local: zsh's warn_create_global warns for each global a function creates. + local __orca_codex_arg __orca_codex_isolate="${ORCA_CODEX_ISOLATE:-1}" + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then + "${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null /dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null /dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in agents|queue|--no-daemon|--remote|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null { } }) - it('retires a waiting row — a wait whose owner is dead is not a wait', async () => { - const server = await startServer() - try { - claudeRow(server, 'waiting') - - expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) - - expect(paneState(server)).toBe('missing') - } finally { - server.stop() - } - }) - it('clears Claude latches even when the stored row already reads done', async () => { // A latch can outlive the row it gated (a restored row, or one written before the inventory // arrived), so a row can read `done` with a latch that would re-gate `working` on the pane's diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 6332a4c23f6..63b6165ce3a 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -333,13 +333,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(setDisplayName).not.toHaveBeenCalled() }) - it('resolves the worktree from the tab when the hook payload omits worktreeId', async () => { - // workingEvent() carries worktreeId: undefined; resolveWorktreeIdForTab supplies it. - const { deps, onRenamed } = makeDeps() - await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - expect(onRenamed).toHaveBeenCalledWith(REPO_ID) - }) - it('runs Git against the backing folder for a folder-workspace instance id', async () => { // Why: instance ids carry a synthetic `::workspace:` suffix that is not // a real directory. The Git cwd must resolve to the folder or `rev-parse` diff --git a/src/main/agent-hooks/hook-script-outside-orca.test.ts b/src/main/agent-hooks/hook-script-outside-orca.test.ts index 6e9a27f6acc..3d508f0261c 100644 --- a/src/main/agent-hooks/hook-script-outside-orca.test.ts +++ b/src/main/agent-hooks/hook-script-outside-orca.test.ts @@ -36,15 +36,6 @@ describe('managed hook outside an Orca terminal', () => { expect(readdirSync(dir)).toEqual(['codex-hook.sh']) }) - it('pane key present but no endpoint: still silent and writes nothing', () => { - const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-')) - const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' }) - expect(res.status).toBe(0) - expect(res.stdout).toBe('') - expect(res.stderr).toBe('') - expect(readdirSync(dir)).toEqual(['codex-hook.sh']) - }) - it('endpoint points at a path that does not exist: silent, exit 0', () => { const dir = mkdtempSync(join(tmpdir(), 'orca-outside-stale-')) const res = runHook(dir, { diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index b848883d22d..39c1999328e 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -284,16 +284,4 @@ describe('installer-utils-remote', () => { }) expect(fs.files.get(path)).toBe('old script') }) - - it('skips a no-op write when contents already match', async () => { - const { sftp, fs } = createFakeSftp() - const path = '/home/u/.claude/settings.json' - await writeHooksJsonRemote(sftp, path, { hooks: {} }) - const beforeKey = fs.files.get(path) - // Re-writing the same payload should produce the same content; there is - // no rename/tmp cycle visible to a downstream observer beyond the - // identical file body. - await writeHooksJsonRemote(sftp, path, { hooks: {} }) - expect(fs.files.get(path)).toBe(beforeKey) - }) }) diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 1a4f087f19b..36044633ec1 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -295,15 +295,6 @@ describe('createManagedCommandMatcher', () => { ).toBe(true) expect(matchPowerShell("/bin/sh '/home/alice/.orca/agent-hooks/copilot-hook.sh'")).toBe(true) }) - - it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => { - // Why: install() must sweep old per-userData commands when migrating to - // the shared ~/.orca script path, or stale launchers keep failing. - expect( - match("/bin/sh '/Users/alice/Library/Application Support/orca/agent-hooks/claude-hook.sh'") - ).toBe(true) - expect(match("/bin/sh '/Users/alice/.orca/agent-hooks/claude-hook.sh'")).toBe(true) - }) }) describe('removeManagedCommands', () => { @@ -462,13 +453,6 @@ describe('getSharedManagedScriptPath', () => { join(homedir(), '.orca', 'agent-hooks', 'claude-hook.sh') ) }) - - it('does not depend on Electron app.getPath, so two Orca instances resolve to the same path', () => { - // Why: using userData here would reintroduce dev/prod settings thrash. - const a = getSharedManagedScriptPath('claude-hook.sh') - const b = getSharedManagedScriptPath('claude-hook.sh') - expect(a).toBe(b) - }) }) describe('writeManagedScript', () => { @@ -495,14 +479,6 @@ describe('wrapPosixHookCommand', () => { ) }) - it('preserves spaces in the script path (Library/Application Support case)', () => { - // Why: Electron's userData on macOS lives under "Application Support" with - // a space. The guard must keep the path quoted so each file test and - // `/bin/sh` see one argument. - const cmd = wrapPosixHookCommand('/Users/a/Library/Application Support/Orca/agent-hooks/x.sh') - expect(cmd).toContain("'/Users/a/Library/Application Support/Orca/agent-hooks/x.sh'") - }) - it('escapes embedded single quotes so the wrapped command stays well-formed', () => { // Why: POSIX single-quote escape renders ' as '\''. Verify a path with an // embedded quote does not break out of the quoting and instead reaches @@ -675,19 +651,6 @@ describe('wrapWindowsHookCommand', () => { expect(ownsStdin).toBeGreaterThan(guard) }) - // Why: a user profile path like `C:\Users\Jane Doe` is the regression from - // #6078 — the raw path used to be split at the space. The wrapper must keep - // the whole path inside the encoded command so shells do not split it. - it('preserves spaces in the script path (user profile with space case)', () => { - const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd') - expect(cmd).toMatch(qualifiedWindowsPowerShellCommand) - expect(decodeWindowsHookCommand(cmd)).toBe( - expectedDecodedWindowsHookCommand( - 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd' - ) - ) - }) - it('keeps cmd.exe percent expansion and caret escapes out of the command line', () => { const cmd = wrapWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd') expect(cmd).not.toContain('%ORCA_TEST%') @@ -938,8 +901,4 @@ describe('buildWindowsAgentHookCurlPostCommand', () => { // hold up the agent. expect(command).toContain('--connect-timeout 0.5 --max-time 1.5') }) - - it('targets the requested hook source endpoint', () => { - expect(buildWindowsAgentHookCurlPostCommand('grok')).toContain('/hook/grok') - }) }) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index ee38da3a7fe..e6ca57078d7 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -346,16 +346,4 @@ describe('startup managed hook reconciliation (STA-5679)', () => { expect(mocks.installClaude).not.toHaveBeenCalled() expect(mocks.installCodex).toHaveBeenCalledTimes(1) }) - - it('still removes through the explicit Settings toggle', async () => { - // Anchors the assertion above: the removers really are wired, so 'skip' is a behavioral - // difference rather than a vacuous constant. - mocks.removeClaude.mockResolvedValue(status('claude', 'not_installed')) - mocks.removeCodex.mockResolvedValue(status('codex', 'not_installed')) - - await applyAgentStatusHooksEnabled(false, { agentStatusHooksEnabled: false }) - - expect(mocks.removeClaude).toHaveBeenCalledTimes(1) - expect(mocks.removeCodex).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index fe7f18ccc32..a667e056669 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -5,6 +5,10 @@ import { } from '../../shared/managed-agent-hook-targets' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' +import { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../../shared/agent-status-hooks-setting' import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { @@ -19,6 +23,10 @@ import { export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry' export { prepareManagedCodexHomeBeforeShellLaunch } from '../codex/managed-home-shell-preflight' +export { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../../shared/agent-status-hooks-setting' type ManagedHookSettings = Partial< Pick @@ -37,12 +45,6 @@ type RemoveOptions = { agents?: readonly AgentHookTarget[] } -export function isAgentStatusHooksEnabled( - settings: Partial> | null | undefined -): boolean { - return settings?.agentStatusHooksEnabled !== false -} - export type StartupManagedHookAction = 'install' | 'skip' // Why never 'remove': this reads THIS instance's settings, but the managed hook files are @@ -60,10 +62,7 @@ export function shouldInstallStartupManagedAgentHook( settings: ManagedHookSettings, agent: AgentHookTarget ): boolean { - return ( - resolveStartupManagedHookAction(settings) === 'install' && - !normalizeDisabledTuiAgents(settings?.disabledTuiAgents).includes(agent) - ) + return isAgentStatusHooksEnabledForAgent(settings, agent) } export function shouldContinueManagedHookStartup( @@ -71,11 +70,7 @@ export function shouldContinueManagedHookStartup( settings: ManagedHookSettings, agent: AgentHookTarget ): boolean { - return ( - !isQuitting && - isAgentStatusHooksEnabled(settings) && - !normalizeDisabledTuiAgents(settings?.disabledTuiAgents).includes(agent) - ) + return !isQuitting && isAgentStatusHooksEnabledForAgent(settings, agent) } function errorStatus(agent: AgentHookTarget, error: unknown): AgentHookInstallStatus { diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts index e80011d803f..dabbb93374c 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -1,15 +1,13 @@ import type * as NodeFsModule from 'node:fs' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import type * as NodeFsPromisesModule from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodeOsModule from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const state = vi.hoisted(() => ({ +const state = vi.hoisted((): { home: string; syncCalls: { name: string; target: string }[] } => ({ home: '', - syncCalls: [] as { name: string; target: string }[], - blockAsyncReads: false + syncCalls: [] })) vi.mock('node:fs', async (importOriginal) => { @@ -30,18 +28,6 @@ vi.mock('node:fs', async (importOriginal) => { return { ...wrapped, default: wrapped } }) -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - default: actual, - readFile: (...args: Parameters) => - state.blockAsyncReads && String(args[0]).startsWith(state.home) - ? new Promise(() => {}) - : actual.readFile(...args) - } -}) - vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal() return { ...actual, default: actual, homedir: () => state.home } @@ -61,11 +47,9 @@ describe('managed hook script refresh stays off the main thread', () => { beforeEach(async () => { state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-')) state.syncCalls = [] - state.blockAsyncReads = false }) afterEach(async () => { - state.blockAsyncReads = false await rm(state.home, { recursive: true, force: true }) }) @@ -85,17 +69,4 @@ describe('managed hook script refresh stays off the main thread', () => { expect(syncCallsUnderHome()).toEqual([]) }) - - it('keeps the event loop responsive while a HOME read is stalled', async () => { - state.blockAsyncReads = true - const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]() - let settled = false - void refresh.then(() => { - settled = true - }) - - await new Promise((resolve) => setTimeout(resolve, 5)) - - expect(settled).toBe(false) - }) }) diff --git a/src/main/agent-hooks/opencode-message-part-flood-bench.test.ts b/src/main/agent-hooks/opencode-message-part-flood-bench.test.ts deleted file mode 100644 index 2c51371050a..00000000000 --- a/src/main/agent-hooks/opencode-message-part-flood-bench.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Benchmark-style regression test for the OpenCode MessagePart flood. - * - * Drives the REAL agent-hook HTTP pipeline (loopback socket, body read, - * JSON.parse, normalization, listener fanout) with two client behaviors: - * - * - "legacy plugin": one POST per streamed part update, each carrying the - * FULL accumulated reply text (how plugin builds before the throttle fix - * behaved) — O(n²) bytes per turn. - * - "throttled plugin": leading + trailing-edge coalesced posts at 250ms - * cadence with text capped at 4000 chars (current plugin behavior). - * - * The byte/post-count assertions are deterministic; wall-clock timings are - * logged as benchmark evidence (see notes/windows-perf-progress.md) but not - * asserted, to keep CI stable. - */ -import { 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 { makePaneKey } from '../../shared/stable-pane-id' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -import { AgentHookServer } from './server' - -const PANE = makePaneKey('tab-bench', '99999999-9999-4999-8999-999999999999') - -// A realistic long streaming reply: ~120 KB final text arriving in 400 -// part updates (OpenCode re-publishes the whole part per append). -const FINAL_REPLY_CHARS = 120_000 -const LEGACY_PART_UPDATES = 400 -// Throttled plugin posts at most one MessagePart per 250ms. A ~30s turn -// yields ~120 posts; we use that worst-case count with the 4000-char cap. -const THROTTLED_POSTS = 120 -const THROTTLED_TEXT_CAP = 4_000 - -describe('OpenCode MessagePart flood benchmark', () => { - let server: AgentHookServer - let tempDir: string - let listenerEvents: number - - beforeEach(async () => { - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) - tempDir = mkdtempSync(join(tmpdir(), 'orca-hook-bench-')) - server = new AgentHookServer() - listenerEvents = 0 - server.setListener(() => { - listenerEvents++ - }) - await server.start({ env: 'production', userDataPath: tempDir }) - }) - - afterEach(() => { - server.stop() - rmSync(tempDir, { recursive: true, force: true }) - }) - - async function postMessagePart(env: Record, text: string): Promise { - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: PANE, - tabId: 'tab-bench', - worktreeId: 'wt-bench', - env: 'production', - payload: { - hook_event_name: 'MessagePart', - role: 'assistant', - text, - messageID: 'msg-bench', - sessionID: 'session-bench' - } - }) - }) - expect(response.status).toBe(204) - } - - it('throttled plugin behavior cuts per-turn hook-pipeline bytes by >40x', async () => { - const env = server.buildPtyEnv() - expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() - - // Legacy: full accumulated text per part update. - let legacyBytes = 0 - const legacyStart = performance.now() - for (let i = 1; i <= LEGACY_PART_UPDATES; i++) { - const text = 'x'.repeat(Math.floor((FINAL_REPLY_CHARS * i) / LEGACY_PART_UPDATES)) - legacyBytes += text.length - await postMessagePart(env, text) - } - const legacyMs = performance.now() - legacyStart - const legacyEvents = listenerEvents - - listenerEvents = 0 - - // Throttled: bounded post count, bounded text. - let throttledBytes = 0 - const throttledStart = performance.now() - for (let i = 1; i <= THROTTLED_POSTS; i++) { - const text = 'x'.repeat(THROTTLED_TEXT_CAP) - throttledBytes += text.length - await postMessagePart(env, text) - } - const throttledMs = performance.now() - throttledStart - const throttledEvents = listenerEvents - - // eslint-disable-next-line no-console - console.log( - `[bench] legacy: ${LEGACY_PART_UPDATES} posts, ${(legacyBytes / 1024 / 1024).toFixed(1)} MB, ` + - `${legacyMs.toFixed(0)} ms, ${legacyEvents} listener fanouts | ` + - `throttled: ${THROTTLED_POSTS} posts, ${(throttledBytes / 1024).toFixed(0)} KB, ` + - `${throttledMs.toFixed(0)} ms, ${throttledEvents} listener fanouts` - ) - - // Deterministic: the turn's total text volume through the main process - // drops from O(n²) (~23 MB here) to O(posts × cap) (~470 KB here, >40x - // less). Real turns stream far more than 400 part updates, so the - // real-world ratio is larger still. - expect(throttledBytes).toBeLessThan(legacyBytes / 40) - expect(THROTTLED_POSTS).toBeLessThan(LEGACY_PART_UPDATES / 3 + 1) - }, 120_000) -}) diff --git a/src/main/agent-hooks/opencode-plugin-settings.ts b/src/main/agent-hooks/opencode-plugin-settings.ts index 7f1f884e331..e05f488c6a1 100644 --- a/src/main/agent-hooks/opencode-plugin-settings.ts +++ b/src/main/agent-hooks/opencode-plugin-settings.ts @@ -1,12 +1,9 @@ import type { PluginSources } from '../../relay/plugin-overlay' -import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' -import { isAgentStatusHooksEnabled } from './managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' function enabled(settings: ManagedHookDetectionSettings, agent: 'opencode' | 'opencode2'): boolean { - return ( - isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled(agent, settings?.disabledTuiAgents) - ) + return isAgentStatusHooksEnabledForAgent(settings, agent) } export function openCodePluginSettingsKey(settings: ManagedHookDetectionSettings): string { diff --git a/src/main/agent-hooks/server-claude-normalization.test.ts b/src/main/agent-hooks/server-claude-normalization.test.ts index dec8896993f..399b984a418 100644 --- a/src/main/agent-hooks/server-claude-normalization.test.ts +++ b/src/main/agent-hooks/server-claude-normalization.test.ts @@ -146,21 +146,6 @@ describe('Claude hook normalization', () => { expect(result?.payload.toolInput).toBeUndefined() }) - it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => { - // Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined. - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'TaskUpdate', - tool_input: { task_id: '3', status: 'in_progress' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('TaskUpdate') - expect(result?.payload.toolInput).toBeUndefined() - }) - it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => { _internals.normalizeHookPayload( 'claude', diff --git a/src/main/agent-hooks/server-claude-permission-visibility.test.ts b/src/main/agent-hooks/server-claude-permission-visibility.test.ts index cc1604b8d16..cf0cd09d5fa 100644 --- a/src/main/agent-hooks/server-claude-permission-visibility.test.ts +++ b/src/main/agent-hooks/server-claude-permission-visibility.test.ts @@ -245,52 +245,6 @@ describe('AgentHookServer listener replay', () => { } }) - it('keeps Claude permission visible for unpreviewable tool input with another tool use id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' }, - tool_use_id: 'toolu-permission-owner-opaque' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-b' }, - tool_use_id: 'toolu-other-opaque' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'BespokeTool' - }) - ]) - } finally { - server.stop() - } - }) - it('keeps Claude permission visible when another tool use completes after permission', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -566,46 +520,6 @@ describe('AgentHookServer listener replay', () => { } }) - it('keeps Claude permission visible when unknown tool previews collide', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { request_id: 'pending-1' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: { request_id: 'other-subagent' } - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'BespokeTool', - toolInput: undefined - }) - ]) - } finally { - server.stop() - } - }) - it('lets Claude permission clear when a new explicit prompt starts', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-closed-tab-suppression.test.ts b/src/main/agent-hooks/server-closed-tab-suppression.test.ts index fb0694c241a..f71ba3a82a0 100644 --- a/src/main/agent-hooks/server-closed-tab-suppression.test.ts +++ b/src/main/agent-hooks/server-closed-tab-suppression.test.ts @@ -270,7 +270,7 @@ describe('AgentHookServer listener replay', () => { // STA-4114: a detach/reattach cycle retires the pane, and nothing lifted the fence. // Reviving only on a new turn cannot help a pane re-attached mid-turn (its remaining // events are agent_end, not a new-turn event) or one re-attached idle. - for (const kind of ['pi', 'omp', 'prime-agent'] as const) { + for (const kind of ['pi', 'omp'] as const) { it(`re-attaching a retired ${kind} pane restores status without needing a new turn`, async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-copilot-normalization.test.ts b/src/main/agent-hooks/server-copilot-normalization.test.ts index af8107debd6..f5fab6520e1 100644 --- a/src/main/agent-hooks/server-copilot-normalization.test.ts +++ b/src/main/agent-hooks/server-copilot-normalization.test.ts @@ -232,20 +232,6 @@ describe('Copilot hook normalization', () => { expect(result?.hasExplicitPrompt).toBe(false) }) - it('Notification(elicitation_dialog) accepts camelCase type and surfaces the question', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'Notification', - notificationType: 'elicitation_dialog', - message: 'Which deployment target should I use?' - }), - 'production' - ) - expect(result?.payload.state).toBe('blocked') - expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?') - }) - it('later progress clears a prior blocked state for the same pane', () => { _internals.normalizeHookPayload( 'copilot', diff --git a/src/main/agent-hooks/server-cursor-normalization.test.ts b/src/main/agent-hooks/server-cursor-normalization.test.ts index 70fd4eebc41..b2e2472502c 100644 --- a/src/main/agent-hooks/server-cursor-normalization.test.ts +++ b/src/main/agent-hooks/server-cursor-normalization.test.ts @@ -204,37 +204,6 @@ describe('Cursor hook normalization', () => { ]) }) - it('tool-heavy turn keeps working across shell and generic tool hooks until stop', () => { - _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'run checks' }), - 'production' - ) - const shell = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeShellExecution', command: 'pnpm test' }), - 'production' - ) - expect(shell?.payload.state).toBe('working') - const tool = _internals.normalizeHookPayload( - 'cursor', - buildBody({ - hook_event_name: 'preToolUse', - tool_name: 'Read', - tool_input: { file_path: '/repo/src/app.ts' } - }), - 'production' - ) - expect(tool?.payload.state).toBe('working') - const stop = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'completed' }), - 'production' - ) - expect(stop?.payload.state).toBe('done') - expect(stop?.payload.prompt).toBe('run checks') - }) - it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => { _internals.normalizeHookPayload( 'cursor', diff --git a/src/main/agent-hooks/server-droid-normalization.test.ts b/src/main/agent-hooks/server-droid-normalization.test.ts index cea0c2bb8df..f6590d9a6fd 100644 --- a/src/main/agent-hooks/server-droid-normalization.test.ts +++ b/src/main/agent-hooks/server-droid-normalization.test.ts @@ -251,15 +251,6 @@ describe('Droid hook normalization', () => { expect(nextTool?.payload.toolInput).toBe('pwd') }) - it('SubagentStop does not close the primary session row', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'SubagentStop' }), - 'production' - ) - expect(result).toBeNull() - }) - it('Stop maps to done and preserves the cached prompt', () => { _internals.normalizeHookPayload( 'droid', diff --git a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts index 91b7c495532..175fecece19 100644 --- a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts +++ b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts @@ -13,8 +13,6 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { AgentHookServer, _internals } from './server' -import { makePaneKey } from '../../shared/stable-pane-id' -import { LEAF_3 } from './server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -136,24 +134,6 @@ describe('Endpoint file lifecycle', () => { } }) - it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'development', - userDataPath, - endpointNamespace: 'com.stablyai.orca.dev.test123' - }) - try { - const env = server.buildPtyEnv() - expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) - expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123') - expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() - expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() - } finally { - server.stop() - } - }) - it('keeps endpoint files separate for parallel dev namespaces', async () => { const firstServer = new AgentHookServer() const secondServer = new AgentHookServer() @@ -228,68 +208,6 @@ describe('Endpoint file lifecycle', () => { } }) - it('ingestRemote stamps connectionId and feeds the listener bypassing HTTP', () => { - const server = new AgentHookServer() - const events: { paneKey: string; connectionId: string | null; payload: unknown }[] = [] - server.setListener((evt) => { - events.push({ - paneKey: evt.paneKey, - connectionId: evt.connectionId, - payload: evt.payload - }) - }) - try { - const remotePane = makePaneKey('tab-3', LEAF_3) - server.ingestRemote( - { - paneKey: remotePane, - tabId: 'tab-3', - worktreeId: 'wt-3', - payload: { - state: 'working', - prompt: 'remote prompt', - agentType: 'claude' - } - }, - 'conn-42' - ) - expect(events).toHaveLength(1) - expect(events[0].paneKey).toBe(remotePane) - expect(events[0].connectionId).toBe('conn-42') - expect(events[0].payload).toMatchObject({ - state: 'working', - prompt: 'remote prompt', - agentType: 'claude' - }) - } finally { - server.setListener(null) - } - }) - - it('ingestRemote ignores malformed envelopes (fail-open)', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - try { - // Missing paneKey - server.ingestRemote({ paneKey: '', payload: { state: 'working' } } as never, 'conn-x') - // Missing payload state - server.ingestRemote({ paneKey: 'tab-1:0', payload: { foo: 'bar' } }, 'conn-x') - // Invalid payload state - server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'nonsense' } }, 'conn-x') - // Empty connection id - server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'working' } }, ' ') - // Wrong types - server.ingestRemote( - { paneKey: 'tab-1:0', payload: 'not-an-object' as unknown } as never, - 'conn-x' - ) - expect(listener).not.toHaveBeenCalled() - } finally { - server.setListener(null) - } - }) - it('endpoint file contents are re-parseable by /bin/sh', async () => { if (process.platform === 'win32') { return diff --git a/src/main/agent-hooks/server-escape-navigation-inference.test.ts b/src/main/agent-hooks/server-escape-navigation-inference.test.ts index 91a605da383..ba2dba544e4 100644 --- a/src/main/agent-hooks/server-escape-navigation-inference.test.ts +++ b/src/main/agent-hooks/server-escape-navigation-inference.test.ts @@ -175,87 +175,6 @@ describe('navigation Escape during an open tool call', () => { expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) }) - it('leaves OMP work running when Escape lands between approval and execution (#9208)', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'omp', - hookEventName: 'tool_approval_resolved', - state: 'working', - prompt: 'refactor the parser', - agentType: 'omp', - toolName: 'bash' - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) - }) - - it('leaves Pi work running when its modal closes over a still-running tool', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'pi', - hookEventName: 'ui_prompt_end', - state: 'working', - prompt: 'refactor the parser', - agentType: 'pi' - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) - }) - - // Why: the closed tool call is the case the old event-name gate let through. The rule no longer - // reads the hook event at all — for these agents a plain Escape is never evidence a turn ended. - it('refuses a Claude Escape even once the tool call has closed', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'claude', - hookEventName: 'PostToolUse', - state: 'working', - prompt: 'migrate the schema', - agentType: 'claude', - toolName: 'Bash' - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) - }) - - it('refuses a Claude Escape on a row that never saw a tool call', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'claude', - hookEventName: 'UserPromptSubmit', - state: 'working', - prompt: 'migrate the schema', - agentType: 'claude' - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) - }) - - // Why: an OSC-parsed row carries no hookEventName; the agent-type rule still covers it, where the - // old event-name gate fell through ungated. - it('refuses an Escape on a row with no hook event at all', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'omp', - hookEventName: '', - state: 'working', - prompt: 'refactor the parser', - agentType: 'omp' - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) - }) - it('still infers Ctrl+C during an open tool call', () => { const server = new AgentHookServer() ingest(server, { @@ -322,69 +241,6 @@ describe('navigation Escape during an open tool call', () => { state: 'working' }) }) - - it('leaves OpenCode and Copilot double Escape unchanged during an open tool call', () => { - for (const agentType of ['opencode', 'copilot'] as const) { - _internals.resetCachesForTests() - const server = new AgentHookServer() - ingest(server, { - source: agentType, - hookEventName: 'PreToolUse', - state: 'working', - prompt: 'build the bundle', - agentType - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - vi.setSystemTime(1_400) - expect(pressInterruptKey(server, 'plain-escape', 2)).toBe(true) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ - state: 'done', - interrupted: true - }) - vi.setSystemTime(1_000) - } - }) - - it('leaves Droid Ctrl+C unchanged during an open tool call', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'droid', - hookEventName: 'PreToolUse', - state: 'working', - prompt: 'run the suite', - agentType: 'droid' - }) - - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'ctrl-c')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ - state: 'working' - }) - }) - - it('rejects a navigation Escape that also has an active child or a stale baseline', () => { - const server = new AgentHookServer() - ingest(server, { - source: 'claude', - hookEventName: 'PreToolUse', - state: 'working', - prompt: 'review loop', - agentType: 'claude', - subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] - }) - vi.setSystemTime(1_200) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(pressInterruptKey(server, 'ctrl-c')).toBe(false) - - // Why: a stale baseline is refused ahead of the navigation guard, for either intent. - vi.setSystemTime(1_000 + 31 * 60 * 1000) - expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ - state: 'working' - }) - }) }) /** The rule has to hold on the path a real agent CLI uses, not only on ingestRemote: the loopback diff --git a/src/main/agent-hooks/server-gemini-normalization.test.ts b/src/main/agent-hooks/server-gemini-normalization.test.ts index d20b55ca9f6..8d79b5eb4de 100644 --- a/src/main/agent-hooks/server-gemini-normalization.test.ts +++ b/src/main/agent-hooks/server-gemini-normalization.test.ts @@ -76,16 +76,6 @@ describe('Gemini hook normalization', () => { expect(result?.payload.toolInput).toBeUndefined() }) - it('AfterAgent reports done without introducing tool fields on its own', () => { - const result = _internals.normalizeHookPayload( - 'gemini', - buildBody({ hook_event_name: 'AfterAgent' }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.toolName).toBeUndefined() - }) - it('AfterAgent carries prompt_response into lastAssistantMessage', () => { const result = _internals.normalizeHookPayload( 'gemini', diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 4999c23d2cb..9a2e7ae7735 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -496,190 +496,4 @@ describe('AgentHookServer listener replay', () => { server.stop() } }) - - it('tracks Codex agent statuses from form-encoded managed hook posts', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - const postCodexHook = async (payload: Record): Promise => { - const params = new URLSearchParams({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'production', - version: env.ORCA_AGENT_HOOK_VERSION ?? '', - payload: JSON.stringify(payload) - }) - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: params - }) - expect(response.status).toBe(204) - } - - await postCodexHook({ - hook_event_name: 'UserPromptSubmit', - prompt: 'ship codex hook status' - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - state: 'working', - agentType: 'codex', - prompt: 'ship codex hook status', - toolName: undefined, - toolInput: undefined - }) - ]) - - await postCodexHook({ - hook_event_name: 'PreToolUse', - tool_name: 'exec_command', - tool_input: { cmd: 'pnpm test', workdir: '/repo' } - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - agentType: 'codex', - prompt: 'ship codex hook status', - toolName: 'exec_command', - toolInput: 'pnpm test' - }) - ]) - - await postCodexHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'exec_command', - tool_input: { cmd: 'git push', workdir: '/repo' } - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'waiting', - agentType: 'codex', - prompt: 'ship codex hook status', - toolName: 'exec_command', - toolInput: 'git push' - }) - ]) - - await postCodexHook({ - hook_event_name: 'Stop', - last_assistant_message: 'done' - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - agentType: 'codex', - prompt: 'ship codex hook status', - lastAssistantMessage: 'done' - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: expect.objectContaining({ - state: 'done', - agentType: 'codex', - prompt: 'ship codex hook status', - lastAssistantMessage: 'done' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('accepts Hermes plugin hook posts on /hook/hermes', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/hermes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'pre_llm_call', - user_message: 'verify Hermes route' - }) - ) - }) - expect(response.status).toBe(204) - - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload: expect.objectContaining({ - state: 'working', - prompt: 'verify Hermes route', - agentType: 'hermes' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('accepts Amp plugin hook posts on /hook/amp', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/amp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'agent.start', - message: 'verify Amp route' - }) - ) - }) - expect(response.status).toBe(204) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload: expect.objectContaining({ - state: 'working', - prompt: 'verify Amp route', - agentType: 'amp' - }) - }) - ) - } finally { - server.stop() - } - }) }) diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index 673c3757579..67f11a51a2b 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -565,20 +565,6 @@ describe('AgentHookServer ingestRemote', () => { expect(server.getStatusSnapshot()).toEqual([]) }) - it('rejects empty connectionId', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '') - expect(listener).not.toHaveBeenCalled() - }) - it('rejects whitespace-only connectionId', () => { const server = new AgentHookServer() const payload = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 3254ee092c9..3752a902dec 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -238,13 +238,6 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) - it('drops the row when the host stops holding the session', () => { - const server = new AgentHookServer() - server.ingestStructuredStatus(summary(), SUBJECT) - server.dropStructuredStatus(SUBJECT) - expect(server.getStatusSnapshot()).toEqual([]) - }) - // The resume-identity remnant a dismissed PTY pane keeps exists so the agent can be resumed in // that pane. A structured session has no pane, and the record store owns its resume identity — // so a remnant here would be an unclearable row that every null-status publish re-minted. @@ -282,27 +275,6 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(dropped).toEqual([STRUCTURED_PANE]) }) - it('schedules no persist for a structured row, while a hook row still does', () => { - const server = new AgentHookServer() - const persists: number[] = [] - const scheduled = server as unknown as { scheduleStatusPersist: () => void } - const original = scheduled.scheduleStatusPersist.bind(server) - scheduled.scheduleStatusPersist = () => { - persists.push(1) - original() - } - - server.ingestStructuredStatus(summary(), SUBJECT) - expect(persists).toHaveLength(0) - - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: null, - payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } - }) - expect(persists).toHaveLength(1) - }) - it('leaves a hook-reported pane alone', () => { const server = new AgentHookServer() server.ingestTerminalStatus({ diff --git a/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts b/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts index d8fd8ecffbf..9619ef6de9d 100644 --- a/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts @@ -87,74 +87,6 @@ describe('AgentHookServer listener replay', () => { } }) - it('does not let late Claude tool hooks with explicit prompt resurrect an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { - state: 'working', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude' - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'Do I have gpu acceleration on on my terminal?', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'PostToolUse', - payload: { - state: 'working', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude', - toolName: 'Read', - toolInput: 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude', - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - it('does not let late Codex tool hooks with explicit prompt resurrect an inferred interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -276,64 +208,6 @@ describe('AgentHookServer listener replay', () => { } }) - it('allows a Claude follow-up prompt after an inferred interrupt to keep working', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'first Claude turn', agentType: 'claude' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'first Claude turn', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'second queued Claude turn', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'second queued Claude turn', - agentType: 'claude', - interrupted: undefined, - receivedAt: 2_000, - stateStartedAt: 2_000 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - it('allows an immediate same-prompt retry after an inferred interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -506,66 +380,4 @@ describe('AgentHookServer listener replay', () => { vi.useRealTimers() } }) - - it('allows non-Claude tool-context working after the stale suppression window', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'repeat task', - baselineAgentType: 'pi', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(16_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'repeat task', - agentType: 'pi', - toolName: 'bash', - toolInput: '/bin/sleep 90' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'repeat task', - agentType: 'pi', - interrupted: undefined, - toolName: 'bash', - toolInput: '/bin/sleep 90', - receivedAt: 16_501, - stateStartedAt: 16_501 - }) - ]) - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index 328dc98c04c..261de8888ac 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -3,13 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentHookSource } from '../../shared/agent-hook-relay' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' import { makePaneKey } from '../../shared/stable-pane-id' import SyncDatabase from '../sqlite/sync-database' import { AgentHookServer } from './server' import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder' -import { defaultOpenCodeDbPath, listOpenCodeDbSessions } from '../opencode/opencode-session-binder' +import { listOpenCodeDbSessions } from '../opencode/opencode-session-binder' const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -237,15 +236,4 @@ describe('listOpenCodeDbSessions', () => { it('returns [] for a missing database instead of throwing', () => { expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) }) - - it('the default path points at the local opencode store', () => { - expect(defaultOpenCodeDbPath()).toMatch(/opencode\.db$/) - }) -}) - -describe('binder registry isolation', () => { - it('a fresh listener state starts unbound', () => { - const state = createHookListenerState() - expect(lookupOpenCodeSessionPane(state, 'ses_live')).toBeUndefined() - }) }) diff --git a/src/main/agent-hooks/server-turn-started-at.test.ts b/src/main/agent-hooks/server-turn-started-at.test.ts new file mode 100644 index 00000000000..63ebd136be6 --- /dev/null +++ b/src/main/agent-hooks/server-turn-started-at.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + getCohortAtEmitMock.mockReturnValue({}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// Distinct host stamps: each event lands on a later millisecond than the one before it. +async function nextMillisecond(): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)) +} + +// The execution host stamps when the main agent's turn began, so every reader (and a reload, which +// starts the renderer with no state history) gets the turn start rather than rebuilding it. +describe('the host-stamped turn start', () => { + const servers: AgentHookServer[] = [] + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-turn-started-at-')) + }) + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + rmSync(userDataPath, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', userDataPath }) + return server + } + + async function post(server: AgentHookServer, payload: Record): Promise { + const response = await postHookEvent(server, buildBody(payload)) + expect(response.status).toBe(204) + await nextMillisecond() + } + + function turnStartedAt(server: AgentHookServer): number | undefined { + return server.getStatusSnapshot()[0]?.turnStartedAt + } + + it('stamps a new main-agent turn and carries it across tools, a wait and its answer', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + const started = turnStartedAt(server) + expect(started).toBe(server.getStatusSnapshot()[0]?.stateStartedAt) + + const bash = { tool_name: 'Bash', tool_input: { command: 'pnpm build' }, tool_use_id: 'tu-1' } + await post(server, { hook_event_name: 'PreToolUse', ...bash }) + await post(server, { hook_event_name: 'PermissionRequest', ...bash }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting' }) + await post(server, { hook_event_name: 'PostToolUse', ...bash }) + // The answer restarted the state clock, not the turn's. + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]?.stateStartedAt).toBeGreaterThan(started!) + expect(turnStartedAt(server)).toBe(started) + + await post(server, { hook_event_name: 'Stop' }) + // Kept on the finished turn, so a reader can time it from the host's own facts. + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', turnStartedAt: started }) + }) + + it('is not stamped by a child agent, and the same prompt sent again is a new turn', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + const started = turnStartedAt(server) + + await post(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'child task', + agent_id: 'child-1' + }) + expect(turnStartedAt(server)).toBe(started) + + // Esc at a permission prompt fires no hook, so the resend follows the stale wait directly. + await post(server, { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: {} }) + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + expect(turnStartedAt(server)).toBeGreaterThan(started!) + }) + + it('is not stamped by a relayed replay', async () => { + const server = await startServer() + const envelope = { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude' as const, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working' as const, prompt: 'relayed', agentType: 'claude' as const } + } + server.ingestRemote(envelope, 'ssh-turn') + const started = turnStartedAt(server) + expect(started).toEqual(expect.any(Number)) + await nextMillisecond() + + server.ingestRemote({ ...envelope, isReplay: true }, 'ssh-turn') + expect(turnStartedAt(server)).toBe(started) + }) + + // A session start lands an idle boundary row: no turn is open until the next prompt. + it('clears on a session boundary', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + expect(turnStartedAt(server)).toEqual(expect.any(Number)) + + await post(server, { hook_event_name: 'SessionStart', source: 'clear' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt') + }) + + // An OSC repaint can land before the prompt hook; it must not count from the finished turn. + it('drops the stamp when a finished main agent runs again with no turn-opening event', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + await post(server, { hook_event_name: 'Stop' }) + expect(turnStartedAt(server)).toEqual(expect.any(Number)) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'Rename the module', agentType: 'claude' } + }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt') + }) + + it('cannot be declared by a producer', async () => { + const server = await startServer() + await post(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'Rename the module', + turnStartedAt: 1 + }) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', turnStartedAt: 1 }) + expect(turnStartedAt(server)).toBeGreaterThan(1) + }) + + it('reaches the live push and survives a restart through the status file', async () => { + const server = await startServer() + const pushed: (number | undefined)[] = [] + server.setListener((status) => pushed.push(status.turnStartedAt)) + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {} }) + const started = turnStartedAt(server) + expect(pushed.at(-1)).toBe(started) + server.flushStatusPersistSync() + server.stop() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.entries[PANE].turnStartedAt).toBe(started) + + const restarted = await startServer() + expect(restarted.getStatusSnapshot()[0]).toMatchObject({ + restoredUnconfirmed: true, + turnStartedAt: started + }) + // The turn goes on after the restart: the next event carries the restored stamp. + await post(restarted, { hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} }) + expect(turnStartedAt(restarted)).toBe(started) + }) +}) diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index f88766217c1..7203f174a0e 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -149,6 +149,7 @@ export function sanitizeHydratedEntry( source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') ? record.compactTrigger : undefined + const turnStartedAt = record.turnStartedAt return { paneKey, source, @@ -170,7 +171,10 @@ export function sanitizeHydratedEntry( retainedForLiveness: retainedForLiveness ? true : undefined, payload, receivedAt, - stateStartedAt + stateStartedAt, + ...(typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0 + ? { turnStartedAt } + : {}) } } diff --git a/src/main/agent-hooks/server/server-row-ownership.test.ts b/src/main/agent-hooks/server/server-row-ownership.test.ts index e466ef879d2..3a63a0f2a75 100644 --- a/src/main/agent-hooks/server/server-row-ownership.test.ts +++ b/src/main/agent-hooks/server/server-row-ownership.test.ts @@ -44,14 +44,6 @@ function row( } describe('status-row change detection', () => { - it('treats the same row object on both sides as unchanged', () => { - const probe = new RowMutationProbe() - const same = row() - expect(probe.commit(same, same)).toBe(false) - expect(probe.commit(null, undefined)).toBe(false) - expect(probe.mutations).toEqual([]) - }) - it('ignores a rebuilt row whose published content is identical', () => { const probe = new RowMutationProbe() const before = row() diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 8863dc178b9..674fa8a0760 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -10,6 +10,7 @@ import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' +import type { AgentStatusState, ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state' import type { EnrichedAgentHookEventPayload } from './server-types' import { agentTypeToPromptSentAgentKind } from './server-status-identity' @@ -18,6 +19,41 @@ import { AgentHookServerStatusDisposition } from './server-status-disposition' /** Bounds the retained observation clock; eviction only degrades a replay to `now`. */ const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024 +function mainAgentState(payload: ParsedAgentStatusPayload): AgentStatusState { + return payload.mainAgent?.state ?? payload.state +} + +/** + * When the main agent's current turn began. Its own turn-opening event stamps it (the same + * classifier the observation boundary uses), except a session boundary, which lands idle with no + * turn open. Replays, child events and identity-only rows restate a turn rather than open one. + */ +function resolveTurnStartedAt( + previous: EnrichedAgentHookEventPayload | undefined, + payload: AgentHookEventPayload, + at: number +): number | undefined { + if ( + payload.source !== undefined && + payload.toolAgentId === undefined && + payload.isReplay !== true && + payload.providerSessionOnly !== true && + isNewTurnEvent(payload.source, payload.hookEventName) + ) { + return payload.payload.sessionBoundary === true ? undefined : at + } + // Why: a settled main agent running again with no opening event (an OSC repaint racing the + // prompt hook) is a turn edge nothing dated; carrying the old stamp would count from that turn. + if ( + previous && + mainAgentState(previous.payload) === 'done' && + mainAgentState(payload.payload) !== 'done' + ) { + return undefined + } + return previous?.turnStartedAt +} + export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { protected refreshTerminalStatusEvidence( previous: EnrichedAgentHookEventPayload, @@ -113,12 +149,15 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn ? previous.stateStartedAt : (observedAt ?? now) + const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now) // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. return { ...payload, receivedAt: now, evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now), - stateStartedAt + stateStartedAt, + // Always written here, so a producer can never declare it. + turnStartedAt } } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 1694c4b1b67..4400b43efee 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -63,6 +63,7 @@ export function toAgentStatusIpcPayload( ? { evidenceObservedAt: entry.evidenceObservedAt } : {}), stateStartedAt: entry.stateStartedAt, + ...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index d1466797798..79b4e569cdd 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -19,6 +19,9 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { * main restart; absent means "never separately observed" and consumers use `receivedAt`. */ evidenceObservedAt?: number stateStartedAt: number + /** When the main agent's current turn began, on this server's clock. Stamped only here, from the + * main agent's own turn-opening event; absent when no such event was seen. */ + turnStartedAt?: number /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ observation?: AgentStatusObservation /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts index 3e460fdc3ba..24fcef6be5d 100644 --- a/src/main/agent-hooks/spool.test.ts +++ b/src/main/agent-hooks/spool.test.ts @@ -16,7 +16,6 @@ import { AGENT_HOOK_SPOOL_MAX_FILES, drainAgentHookSpool, launchTokenHash, - readSpoolRecords, type SpoolRecord } from '../../shared/agent-hook-spool' import { AgentHookServer, _internals } from './server' @@ -35,16 +34,6 @@ describe('agent hook spool', () => { expect(spoolLine).toContain('"$spool_now" "$payload"') }) - it('drops torn lines while retaining complete records', () => { - const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) - const file = join(dir, 'pane.jsonl') - writeFileSync( - file, - '\n{"paneKey":"tab:1","source":"codex","receivedAt":1,"payload":{}}\n{"paneKey":' - ) - expect(readSpoolRecords(file, 1)).toHaveLength(1) - }) - it('waits for a newline before replaying a complete-looking final record', () => { const dir = mkdtempSync(join(tmpdir(), 'orca-spool-unterminated-')) const spool = join(dir, 'spool') @@ -63,7 +52,6 @@ describe('agent hook spool', () => { getPersistedLaunchTokenHash: () => undefined, ingest: (value: SpoolRecord) => ingested.push(value) } - expect(readSpoolRecords(file)).toHaveLength(0) expect(drainAgentHookSpool(options)).toBe(0) expect(readFileSync(file, 'utf8')).toBe(record) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts index df307d87035..6be74f65bbf 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts @@ -46,7 +46,6 @@ describe('windows PowerShell hook launcher', () => { // it. Restoring either denied switch re-breaks every hook on an AV host. const command = wrapWindowsPowerShellEncodedCommand('exit 0') - expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile') expect(command).toMatch(/ -NoProfile -EncodedCommand [A-Za-z0-9+/=]+$/) }) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts index d8f504beca3..d23d59288b4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -81,8 +81,6 @@ describe('waitForWslRelaySentinel', () => { emitStdout(child, sentinel.subarray(0, mid)) emitStdout(child, sentinel.subarray(mid)) const transport = await promise - expect(typeof transport.write).toBe('function') - expect(typeof transport.onData).toBe('function') transport.pauseReads?.() transport.resumeReads?.() expect(child.stdout.pause).toHaveBeenCalledOnce() diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 5e7521ae468..c18499a5ae2 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -10,7 +10,9 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { runCodexAppServerSession } from './codex/codex-app-server-session' const testState = { fakeHomeDir: '', @@ -46,6 +48,17 @@ const { } = await import('./agent-trust-presets') const { runExclusivelyForCodexTrustConfig } = await import('./codex/codex-trust-config-mutation-queue') +const { getLocalCodexTrustConfigFiles } = await import('./codex/codex-home-paths') + +// Why: fixture tests pin Orca's half; only the real binary proves Codex reads the key +// Orca writes. CI sets REQUIRED so a missing binary fails instead of skipping. +const codexTrustContract = { + binary: process.env.ORCA_CODEX_TRUST_CONTRACT_BINARY, + version: process.env.ORCA_CODEX_TRUST_CONTRACT_VERSION +} +if (process.env.ORCA_CODEX_TRUST_CONTRACT_REQUIRED === '1' && !codexTrustContract.binary) { + throw new Error('ORCA_CODEX_TRUST_CONTRACT_REQUIRED=1 but no Codex binary was given') +} beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) @@ -224,7 +237,7 @@ describe('markCodexProjectTrusted', () => { }) try { const held = runExclusivelyForCodexTrustConfig(configPath, () => grantHoldingTheFile) - const marked = markCodexProjectTrusted(workspace) + const marked = markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) await Promise.resolve() expect(existsSync(configPath)).toBe(false) @@ -237,76 +250,134 @@ describe('markCodexProjectTrusted', () => { } }) - it('trusts the main repository root for a linked worktree without reading commondir', async () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) - const repository = join(fixtureRoot, 'repo') - const workspace = join(fixtureRoot, 'worktrees', 'feature') - const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') - try { - mkdirSync(worktreeGitDir, { recursive: true }) - mkdirSync(workspace, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8') - writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8') - - await markCodexProjectTrusted(workspace) - - const repositoryRoot = realpathSync.native(repository) - const workspaceRoot = realpathSync.native(workspace) - const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - const runtimeConfigPath = join( - testState.userDataDir, - 'codex-runtime-home', - 'home', - 'config.toml' - ) - for (const written of [ - readFileSync(configPath, 'utf-8'), - readFileSync(runtimeConfigPath, 'utf-8') - ]) { - expect(written).toContain(`[projects."${escapeTomlBasicString(repositoryRoot)}"]`) - expect(written).not.toContain(`[projects."${escapeTomlBasicString(workspaceRoot)}"]`) + // Why: Codex checks the cwd's own entry before any repo root, so the workspace key + // must satisfy it whatever the git layout. + describe('linked worktree trust key', { timeout: 120_000 }, () => { + let fixtureRoot = '' + beforeAll(() => { + if (codexTrustContract.binary) { + const version = runProcessSync({ program: codexTrustContract.binary, args: ['--version'] }) + expect(version.stdout.trim()).toBe(`codex-cli ${codexTrustContract.version}`) } - } finally { + }) + beforeEach(() => { + fixtureRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'orca-codex-layout-'))) + }) + afterEach(() => { rmSync(fixtureRoot, { recursive: true, force: true }) + }) + + function git(cwd: string, ...args: string[]): void { + const result = runProcessSync({ + program: 'git', + args: ['-c', 'user.name=Orca', '-c', 'user.email=orca@example.com', ...args], + cwd + }) + expect(result.code, result.stderr).toBe(0) } - }) - it('does not broaden trust through arbitrary or adversarial Git metadata', async () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-')) - const workspace = join(fixtureRoot, 'workspace') - const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature') - const unrelatedRoot = join(fixtureRoot, 'unrelated') - try { - mkdirSync(arbitraryGitDir, { recursive: true }) - mkdirSync(workspace, { recursive: true }) - mkdirSync(unrelatedRoot, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8') - writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8') - - await markCodexProjectTrusted(workspace) - const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature') - mkdirSync(structuredGitDir, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8') - writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8') - await markCodexProjectTrusted(workspace) - - const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8') - expect(written).toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` - ) - expect(written).not.toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]` - ) - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) + function initRepoWithCommit(repo: string, ...initArgs: string[]): void { + mkdirSync(repo, { recursive: true }) + git(repo, 'init', '-q', ...initArgs) + git(repo, '-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '-m', 'init') } + + async function expectWorkspaceTrusted(workspace: string): Promise { + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + const runtimeHome = join(testState.userDataDir, 'codex-runtime-home', 'home') + const [system, runtime] = [ + join(testState.fakeHomeDir, '.codex', 'config.toml'), + join(runtimeHome, 'config.toml') + ].map((path) => + [...readFileSync(path, 'utf-8').matchAll(/^\[projects\."(.*)"\]$/gm)].map((m) => + m[1].replaceAll('\\\\', '\\') + ) + ) + expect(system).toEqual([workspace]) + expect(runtime).toEqual([workspace]) + if (codexTrustContract.binary) { + expect(await codexSandboxFor(workspace, runtimeHome)).toBe('workspaceWrite') + const emptyHome = join(fixtureRoot, 'empty-codex-home') + mkdirSync(emptyHome, { recursive: true }) + expect(await codexSandboxFor(workspace, emptyHome)).toBe('readOnly') + } + } + + // Why thread/start: its default sandbox and the TUI trust prompt read the same + // project lookup, so workspaceWrite (not readOnly) means no prompt. + async function codexSandboxFor(cwd: string, codexHome: string): Promise { + const binary = codexTrustContract.binary! + return runCodexAppServerSession( + { + command: binary, + args: ['-c', 'features.plugins=false', 'app-server'], + cliPath: binary, + env: { CODEX_HOME: codexHome, HOME: testState.fakeHomeDir }, + timeoutMs: 60_000 + }, + async (rpc) => { + const started = await rpc.request('thread/start', { cwd }) + const sandbox = isRecord(started) ? started.sandbox : undefined + return isRecord(sandbox) ? sandbox.type : undefined + } + ) + } + + it('trusts a linked worktree of a standard repository', async () => { + const repo = join(fixtureRoot, 'repo') + initRepoWithCommit(repo) + git(repo, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + it('trusts a worktree whose main checkout .git points at a sibling .bare dir', async () => { + const source = join(fixtureRoot, 'source') + const project = join(fixtureRoot, 'project') + initRepoWithCommit(source) + git(fixtureRoot, 'clone', '-q', '--bare', source, join(project, '.bare')) + writeFileSync(join(project, '.git'), 'gitdir: ./.bare\n', 'utf-8') + git(project, 'worktree', 'add', '-q', join(project, 'wt')) + await expectWorkspaceTrusted(join(project, 'wt')) + }) + + it('trusts a worktree of a bare repository', async () => { + const source = join(fixtureRoot, 'source') + const bare = join(fixtureRoot, 'proj.git') + initRepoWithCommit(source) + git(fixtureRoot, 'clone', '-q', '--bare', source, bare) + git(bare, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + it('trusts a worktree of a --separate-git-dir repository', async () => { + const repo = join(fixtureRoot, 'repo') + initRepoWithCommit(repo, `--separate-git-dir=${join(fixtureRoot, 'repo.git')}`) + git(repo, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + it('trusts a worktree whose parent .git points at another repository', async () => { + const source = join(fixtureRoot, 'source') + initRepoWithCommit(source) + git(fixtureRoot, 'clone', '-q', '--bare', source, join(fixtureRoot, 'proj.git')) + git(fixtureRoot, 'clone', '-q', '--bare', source, join(fixtureRoot, 'other.git')) + writeFileSync(join(fixtureRoot, '.git'), 'gitdir: ./other.git\n', 'utf-8') + git(join(fixtureRoot, 'proj.git'), 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + it('trusts a plain folder workspace', async () => { + const folder = join(fixtureRoot, 'folder') + mkdirSync(folder) + await expectWorkspaceTrusted(folder) + }) }) it('writes ~/.codex/config.toml with the project marked trusted', async () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { const realpath = realpathSync.native(workspace) - await markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const runtimeConfigPath = join( testState.userDataDir, @@ -360,7 +431,7 @@ describe('markCodexProjectTrusted', () => { 'utf-8' ) - await markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8') const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8') @@ -381,3 +452,7 @@ describe('markCodexProjectTrusted', () => { function escapeTomlBasicString(value: string): string { return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index d84fc68a468..ffd1ca6b89a 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,12 +1,12 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { join } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' -import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' +import type { TuiAgentConfig } from '../shared/tui-agent-config' -export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' +export type AgentTrustPreset = NonNullable /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or @@ -161,58 +161,28 @@ export function markAntigravityWorkspaceTrusted(workspacePath: string): void { * * Verified against codex-rs/tui/src/onboarding/trust_directory.rs and * codex-rs/core/src/config/config_tests.rs in the Codex CLI source. + * + * `configFiles` names every config.toml the launched Codex may read, in the + * hook installer's lock order (an Orca-owned CODEX_HOME before the system one). */ -export function markCodexProjectTrusted(workspacePath: string): Promise { - const absPath = resolveCodexProjectTrustRoot(workspacePath) - const systemTomlPath = join(homedir(), '.codex', 'config.toml') - // Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust - // preset must also update the runtime config Codex will actually read. - const runtimeTomlPath = join(getOrcaManagedCodexHomePath(), 'config.toml') +export function markCodexProjectTrusted( + workspacePath: string, + configFiles: readonly string[] +): Promise { + // Why: Codex checks the cwd's own entry before the repo root, so no git-layout logic is needed. + const absPath = canonicalize(workspacePath) // Why (#16441): hook installs now await a codex app-server grant, so an // unqueued write here can land inside their capture->restore window and be // reverted. Same runtime-before-system lock order the installer takes. - return runExclusivelyForCodexTrustConfig(runtimeTomlPath, () => - runExclusivelyForCodexTrustConfig(systemTomlPath, async () => { - upsertProjectTrustLevel(systemTomlPath, absPath, 'trusted') - upsertProjectTrustLevel(runtimeTomlPath, absPath, 'trusted') - }) + const write = configFiles.reduceRight<() => Promise>( + (inner, configFile) => () => runExclusivelyForCodexTrustConfig(configFile, inner), + async () => { + for (const configFile of configFiles) { + upsertProjectTrustLevel(configFile, absPath, 'trusted') + } + } ) -} - -function resolveCodexProjectTrustRoot(workspacePath: string): string { - const absPath = canonicalize(workspacePath) - try { - const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() - if (!gitDirReference.startsWith('gitdir:')) { - return absPath - } - const gitDirPath = gitDirReference.slice('gitdir:'.length).trim() - if (!gitDirPath) { - return absPath - } - const gitDir = resolve(absPath, gitDirPath) - const worktreesDir = dirname(gitDir) - if (basename(worktreesDir) !== 'worktrees') { - return absPath - } - // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. - const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() - if (!gitDirBacklink) { - return absPath - } - const resolvedBacklink = resolve(gitDir, gitDirBacklink) - const workspaceGitFile = join(absPath, '.git') - if ( - resolvedBacklink !== workspaceGitFile && - canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile) - ) { - return absPath - } - // Why: mirror Codex's validated .git/worktrees/ traversal instead of trusting arbitrary commondir contents. - return canonicalize(dirname(dirname(worktreesDir))) - } catch { - return absPath - } + return write() } function canonicalize(p: string): string { diff --git a/src/main/agent-trust-write-deadline.ts b/src/main/agent-trust-write-deadline.ts index 0effa8f69bc..450966a67de 100644 --- a/src/main/agent-trust-write-deadline.ts +++ b/src/main/agent-trust-write-deadline.ts @@ -4,10 +4,8 @@ import type { AgentTrustPreset } from './agent-trust-presets' * Why: callers hold launch admission on a trust write, but the write is not * self-bounding. The local Codex writer queues on the per-`config.toml` lane * shared with hook installs and app-server trust grants (up to 10s native / - * 30s WSL each, with no cap on how many are already queued), and the SSH - * writer chains a `session.resolveHome` round trip plus unbounded SFTP - * read/write calls over a link that may be half-open. Without a cap the user - * clicks "start agent" and nothing happens, with no error. + * 30s WSL each, with no cap on how many are already queued). Without a cap the + * user clicks "start agent" and nothing happens, with no error. * * 20s covers a native grant session holding the lane with headroom. Past that * we stop waiting and let the launch proceed *untrusted*: the write is never @@ -17,6 +15,9 @@ import type { AgentTrustPreset } from './agent-trust-presets' */ export const AGENT_TRUST_WRITE_DEADLINE_MS = 20_000 +/** Writers with no shared lock lane, local or on the relay: a stall past this means the agent asks. */ +export const SHORT_AGENT_TRUST_WRITE_DEADLINE_MS = 1_500 + /** * Awaits a trust write but never longer than the deadline. Rejections * propagate to the caller's best-effort catch; a deadline miss is reported as diff --git a/src/main/agent-workspace-trust-guard-failure.test.ts b/src/main/agent-workspace-trust-guard-failure.test.ts new file mode 100644 index 00000000000..9f5e1ab7d9b --- /dev/null +++ b/src/main/agent-workspace-trust-guard-failure.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + copilot: vi.fn<(path: string) => void>() +})) + +vi.mock('../shared/home-or-filesystem-root', () => ({ + isTooBroadToPreTrust: () => { + throw new Error('homedir unavailable') + } +})) +vi.mock('./agent-trust-presets', () => ({ + markCodexProjectTrusted: vi.fn(async () => {}), + markCursorWorkspaceTrusted: vi.fn(), + markCopilotFolderTrusted: mocks.copilot, + markAntigravityWorkspaceTrusted: vi.fn() +})) + +import { applyAgentWorkspaceTrust } from './agent-workspace-trust' + +describe('applyAgentWorkspaceTrust when the breadth guard fails', () => { + it('writes nothing and never fails the launch', async () => { + await expect( + applyAgentWorkspaceTrust('copilot', '/workspace/app', { + env: {}, + claudeAuth: null, + wslDistro: null, + connectionId: null + }) + ).resolves.toEqual({}) + expect(mocks.copilot).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-workspace-trust-spawn.test.ts b/src/main/agent-workspace-trust-spawn.test.ts new file mode 100644 index 00000000000..17204bd0f5d --- /dev/null +++ b/src/main/agent-workspace-trust-spawn.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TUI_AGENT_CONFIG, isTuiAgent } from '../shared/tui-agent-config' + +const applyAgentWorkspaceTrust = vi.hoisted(() => + vi.fn<(preset: string, path: string, context: unknown) => Promise>(async () => ({})) +) +vi.mock('./agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) + +import { applyAgentWorkspaceTrustToSpawn } from './agent-workspace-trust-spawn' + +const store = { + getFolderWorkspace: (id: string) => (id === 'fw-1' ? { folderPath: '/notes' } : undefined) +} + +function spawnArgs(overrides: Partial[0]>) { + return { + launchAgent: 'claude', + worktreeId: 'repo-1::/repo/wt', + cwd: undefined, + store, + isFreshLaunch: true, + settings: { agentWorkspaceTrustEnabled: true }, + env: { CLAUDE_CONFIG_DIR: '/cfg' }, + claudeAuth: null, + wslDistro: null, + connectionId: null, + spawnOptions: {}, + ...overrides + } +} + +const PRESET_AGENTS = Object.keys(TUI_AGENT_CONFIG) + .filter(isTuiAgent) + .filter((agent) => TUI_AGENT_CONFIG[agent].preflightTrust) + +beforeEach(() => { + applyAgentWorkspaceTrust.mockClear() +}) + +describe('applyAgentWorkspaceTrustToSpawn', () => { + it('covers both Claude launch modes and every other preset agent', () => { + expect(PRESET_AGENTS).toEqual( + expect.arrayContaining([ + 'claude', + 'claude-agent-teams', + 'codex', + 'cursor', + 'copilot', + 'qoder', + 'antigravity' + ]) + ) + expect(TUI_AGENT_CONFIG['claude-agent-teams'].preflightTrust).toBe('claude') + }) + + describe.each(PRESET_AGENTS)('%s', (agent) => { + const preset = TUI_AGENT_CONFIG[agent].preflightTrust + + it.each([ + ['a linked worktree', 'repo-1::/repo/wt', '/repo/wt'], + ['a main checkout', 'repo-1::/repo', '/repo'], + [ + 'a folder repo workspace', + 'repo-2::/folder::workspace:0f8fad5b-d9cb-469f-a165-70867728950e', + '/folder' + ], + ['a folder workspace', 'folder:fw-1', '/notes'] + ])('trusts %s', async (_label, worktreeId, expectedPath) => { + await applyAgentWorkspaceTrustToSpawn(spawnArgs({ launchAgent: agent, worktreeId })) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith(preset, expectedPath, { + env: { CLAUDE_CONFIG_DIR: '/cfg' }, + claudeAuth: null, + wslDistro: null, + connectionId: null + }) + }) + + it('does nothing with the setting off', () => { + expect( + applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: agent, settings: { agentWorkspaceTrustEnabled: false } }) + ) + ).toBeNull() + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + }) + + it('does nothing for a restored or reattached pane', () => { + expect( + applyAgentWorkspaceTrustToSpawn(spawnArgs({ launchAgent: agent, isFreshLaunch: false })) + ).toBeNull() + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + }) + + it('routes an SSH launch to its connection', async () => { + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: agent, worktreeId: 'repo-1::/srv/wt', connectionId: 'ssh-1' }) + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + preset, + '/srv/wt', + expect.objectContaining({ connectionId: 'ssh-1' }) + ) + }) + + it('passes the WSL distro so the writer targets the guest or skips', async () => { + await applyAgentWorkspaceTrustToSpawn(spawnArgs({ launchAgent: agent, wslDistro: 'Ubuntu' })) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + preset, + '/repo/wt', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) + }) + }) + + it('forwards the relay field the dispatcher returns for an SSH launch', async () => { + applyAgentWorkspaceTrust.mockResolvedValueOnce({ + agentWorkspaceTrust: { workspacePath: '/srv/wt' } + }) + const spawnOptions = {} + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ worktreeId: 'repo-1::/srv/wt', connectionId: 'ssh-1', spawnOptions }) + ) + expect(spawnOptions).toEqual({ agentWorkspaceTrust: { workspacePath: '/srv/wt' } }) + }) + + it('trusts Codex in a floating terminal at the folder it starts in', async () => { + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: 'codex', worktreeId: 'global-floating-terminal', cwd: '/Users/me' }) + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/Users/me', expect.anything()) + }) + + it('trusts Codex at a subfolder it starts in, which its lookup keys on', async () => { + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: 'codex', worktreeId: 'folder:fw-1', cwd: '/notes/sub' }) + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/notes/sub', expect.anything()) + }) + + it.each(PRESET_AGENTS.filter((agent) => agent !== 'codex'))( + 'trusts the workspace root for %s, whose trust covers its subfolders or matches the root', + async (agent) => { + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: agent, worktreeId: 'folder:fw-1', cwd: '/notes/sub' }) + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + TUI_AGENT_CONFIG[agent].preflightTrust, + '/notes', + expect.anything() + ) + } + ) + + it('treats an unset setting as on', async () => { + await applyAgentWorkspaceTrustToSpawn(spawnArgs({ settings: undefined })) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['an agent with no trust preset', { launchAgent: 'gemini' }], + ['a plain terminal with no declared agent', { launchAgent: undefined }], + ['a floating terminal', { worktreeId: 'global-floating-terminal', cwd: '/Users/me' }], + [ + 'Codex with no start folder and no workspace', + { launchAgent: 'codex', worktreeId: 'global-floating-terminal' } + ], + ['an unknown folder workspace', { worktreeId: 'folder:missing' }], + ['a spawn with no workspace', { worktreeId: undefined }] + ])('does nothing, and gives the builder nothing to await, for %s', (_label, overrides) => { + expect(applyAgentWorkspaceTrustToSpawn(spawnArgs(overrides))).toBeNull() + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-workspace-trust-spawn.ts b/src/main/agent-workspace-trust-spawn.ts new file mode 100644 index 00000000000..f893529b53b --- /dev/null +++ b/src/main/agent-workspace-trust-spawn.ts @@ -0,0 +1,89 @@ +import { TUI_AGENT_CONFIG, isTuiAgent } from '../shared/tui-agent-config' +import type { GlobalSettings } from '../shared/global-settings-types' +import { resolveTerminalWorkspacePath } from '../shared/terminal-startup-cwd' +import { + applyAgentWorkspaceTrust, + type AgentTrustLaunchContext, + type AgentTrustSpawnFields +} from './agent-workspace-trust' +import { AGENT_TRUST_KEYED_BY_START_FOLDER } from './execution-host-workspace-trust' + +type AgentWorkspaceTrustSetting = + | Pick + | null + | undefined + +function isAgentWorkspaceTrustOn(settings: AgentWorkspaceTrustSetting): boolean { + return settings?.agentWorkspaceTrustEnabled !== false +} + +/** + * The one place Orca pre-trusts a workspace: every Orca-started agent PTY passes + * through a spawn builder with its declared `launchAgent`, which survives setup-script + * wrapping. Reattaches and restores are skipped so trust is never re-run for them. + * Structured Codex chats have no PTY and enter below instead. Returns null when there is nothing to write, so other spawns take no extra tick: + * the builders arbitrate pane-spawn reservation races that a tick can reorder. + */ +export function applyAgentWorkspaceTrustToSpawn( + args: { + launchAgent: unknown + /** Worktree or folder workspace id; its root is the folder the agent is trusted in. */ + worktreeId: string | undefined + /** The resolved folder the agent starts in; floating terminals have only this. */ + cwd: string | undefined + store: { getFolderWorkspace: (id: string) => { folderPath: string } | undefined } | undefined + isFreshLaunch: boolean + settings: AgentWorkspaceTrustSetting + spawnOptions: AgentTrustSpawnFields + } & AgentTrustLaunchContext +): Promise | null { + if (!args.isFreshLaunch || !isAgentWorkspaceTrustOn(args.settings)) { + return null + } + const preset = isTuiAgent(args.launchAgent) + ? TUI_AGENT_CONFIG[args.launchAgent].preflightTrust + : undefined + if (!preset) { + return null + } + const workspaceRoot = resolveTerminalWorkspacePath( + args.worktreeId, + (folderWorkspaceId) => args.store?.getFolderWorkspace(folderWorkspaceId)?.folderPath + ) + const workspacePath = AGENT_TRUST_KEYED_BY_START_FOLDER[preset] + ? args.cwd || workspaceRoot + : workspaceRoot + if (!workspacePath) { + return null + } + return applyAgentWorkspaceTrust(preset, workspacePath, { + env: args.env, + claudeAuth: args.claudeAuth, + wslDistro: args.wslDistro, + connectionId: args.connectionId + }).then((fields) => { + if (fields.agentWorkspaceTrust) { + args.spawnOptions.agentWorkspaceTrust = fields.agentWorkspaceTrust + } + }) +} + +/** + * Structured Codex chats are the one agent start with no PTY. Codex's app-server trusts the + * folder itself only when the chat may write it, so a read-only chat would ignore `.codex` config. + */ +export async function applyStructuredCodexWorkspaceTrust(args: { + workspacePath: string + launchEnv: Record + settings: AgentWorkspaceTrustSetting +}): Promise { + if (!isAgentWorkspaceTrustOn(args.settings)) { + return + } + await applyAgentWorkspaceTrust('codex', args.workspacePath, { + env: args.launchEnv, + claudeAuth: null, + wslDistro: null, + connectionId: null + }) +} diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts new file mode 100644 index 00000000000..2ed9d1d2a15 --- /dev/null +++ b/src/main/agent-workspace-trust.test.ts @@ -0,0 +1,285 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ClaudeFolderTrustFile from './claude/claude-folder-trust-file' + +const CODEX_CONFIG_FILES = ['/orca/codex-home/config.toml', '/home/u/.codex/config.toml'] + +const mocks = vi.hoisted(() => ({ + codex: vi.fn<(path: string, configFiles: readonly string[]) => Promise>(async () => {}), + cursor: vi.fn<(path: string) => void>(), + copilot: vi.fn<(path: string) => void>(), + antigravity: vi.fn<(path: string) => void>(), + qoder: vi.fn<(path: string) => void>(), + claudeGrant: vi.fn() +})) + +vi.mock('./agent-trust-presets', () => ({ + markCodexProjectTrusted: mocks.codex, + markCursorWorkspaceTrusted: mocks.cursor, + markCopilotFolderTrusted: mocks.copilot, + markAntigravityWorkspaceTrusted: mocks.antigravity +})) +vi.mock('./codex/codex-home-paths', () => ({ + getLocalCodexTrustConfigFiles: () => CODEX_CONFIG_FILES +})) +vi.mock('./qoder/workspace-trust', () => ({ markQoderWorkspaceTrusted: mocks.qoder })) +vi.mock('./claude/claude-folder-trust-file', async (importOriginal) => { + const actual = await importOriginal() + mocks.claudeGrant.mockImplementation(actual.grantClaudeWorkspaceTrust) + return { ...actual, grantClaudeWorkspaceTrust: mocks.claudeGrant } +}) + +import { applyAgentWorkspaceTrust, type AgentTrustLaunchContext } from './agent-workspace-trust' +import { clearWslHomeCache, rememberWslHome } from './wsl-home-cache' +import { + AGENT_TRUST_WRITE_DEADLINE_MS, + SHORT_AGENT_TRUST_WRITE_DEADLINE_MS +} from './agent-trust-write-deadline' + +const WORKSPACE = '/workspace/app' +const local: AgentTrustLaunchContext = { + env: {}, + claudeAuth: null, + wslDistro: null, + connectionId: null +} + +function pending(): { promise: Promise; release: () => void } { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('applyAgentWorkspaceTrust on this machine', () => { + it.each([ + ['codex', mocks.codex], + ['cursor', mocks.cursor], + ['copilot', mocks.copilot], + ['qoder', mocks.qoder], + // Why: one of the old copied switches omitted Antigravity, so workers launched there asked. + ['antigravity', mocks.antigravity] + ] as const)('writes the %s preset for the workspace', async (preset, writer) => { + await expect(applyAgentWorkspaceTrust(preset, WORKSPACE, local)).resolves.toEqual({}) + expect(writer).toHaveBeenCalledWith( + WORKSPACE, + ...(preset === 'codex' ? [CODEX_CONFIG_FILES] : []) + ) + }) + + it('contains a rejected or throwing write so the launch proceeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.codex.mockRejectedValueOnce(new Error('write failed')) + mocks.cursor.mockImplementationOnce(() => { + throw new Error('write failed') + }) + await expect(applyAgentWorkspaceTrust('codex', WORKSPACE, local)).resolves.toEqual({}) + await expect(applyAgentWorkspaceTrust('cursor', WORKSPACE, local)).resolves.toEqual({}) + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + it('gives only Codex the long deadline its shared config lane needs', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const codexWrite = pending() + mocks.codex.mockReturnValueOnce(codexWrite.promise) + let codexSettled = false + const codex = applyAgentWorkspaceTrust('codex', WORKSPACE, local).then(() => { + codexSettled = true + }) + await vi.advanceTimersByTimeAsync(SHORT_AGENT_TRUST_WRITE_DEADLINE_MS + 1) + expect(codexSettled).toBe(false) + await vi.advanceTimersByTimeAsync(AGENT_TRUST_WRITE_DEADLINE_MS) + await codex + expect(codexSettled).toBe(true) + codexWrite.release() + warn.mockRestore() + }) + + // Why: a config dir that does not exist keeps a regression here from writing a real config. + const noConfig = { ...local, env: { CLAUDE_CONFIG_DIR: join(tmpdir(), 'orca-no-claude-config') } } + it.each([ + ['the home folder', homedir(), noConfig], + [ + 'the home the spawn env names', + '/home/agent', + { ...noConfig, env: { ...noConfig.env, HOME: '/home/agent' } } + ], + ['a filesystem root', '/', noConfig], + ['a drive root', 'C:\\', noConfig] + ])( + 'never pre-trusts %s for an agent that inherits trust from it', + async (_label, workspacePath, context) => { + for (const preset of ['claude', 'copilot', 'qoder'] as const) { + await expect(applyAgentWorkspaceTrust(preset, workspacePath, context)).resolves.toEqual({}) + } + for (const writer of [mocks.copilot, mocks.qoder, mocks.claudeGrant]) { + expect(writer).not.toHaveBeenCalled() + } + } + ) + + it('trusts the home folder for Codex, Cursor and Antigravity, whose trust there stays there', async () => { + for (const preset of ['codex', 'cursor', 'antigravity'] as const) { + await applyAgentWorkspaceTrust(preset, homedir(), noConfig) + } + expect(mocks.codex).toHaveBeenCalledWith(homedir(), CODEX_CONFIG_FILES) + expect(mocks.cursor).toHaveBeenCalledWith(homedir()) + expect(mocks.antigravity).toHaveBeenCalledWith(homedir()) + }) + + it('never pre-trusts a home reached through a symlink, since the writers store the realpath', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-agent-trust-home-'))) + try { + const home = join(root, 'home') + mkdirSync(home) + symlinkSync(home, join(root, 'home-link'), 'junction') + const cases = [ + [join(root, 'home-link'), home], + [home, join(root, 'home-link')] + ] + for (const [workspacePath, homePath] of cases) { + const context = { ...noConfig, env: { ...noConfig.env, HOME: homePath } } + for (const preset of ['claude', 'copilot', 'qoder'] as const) { + await applyAgentWorkspaceTrust(preset, workspacePath, context) + } + } + for (const writer of [mocks.copilot, mocks.qoder, mocks.claudeGrant]) { + expect(writer).not.toHaveBeenCalled() + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it.each([ + ['a WSL distro', { ...local, wslDistro: 'Ubuntu' }, WORKSPACE], + ['a WSL UNC workspace', local, '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt'] + ])('never writes the Windows home for %s', async (_label, context, workspacePath) => { + for (const preset of ['codex', 'cursor', 'copilot', 'qoder', 'antigravity'] as const) { + await applyAgentWorkspaceTrust(preset, workspacePath, context) + } + for (const writer of [mocks.codex, mocks.cursor, mocks.copilot, mocks.qoder]) { + expect(writer).not.toHaveBeenCalled() + } + expect(mocks.antigravity).not.toHaveBeenCalled() + }) + describe('for Claude in a WSL guest', () => { + const wslAuth = { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/u/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'test' + } as const + const wsl = { ...local, claudeAuth: wslAuth, wslDistro: 'Ubuntu' } + + afterEach(() => { + clearWslHomeCache() + }) + + it("writes nothing while the guest's home is unknown", async () => { + await applyAgentWorkspaceTrust('claude', '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', wsl) + expect(mocks.claudeGrant).not.toHaveBeenCalled() + }) + + it("never pre-trusts the guest's home, however the distro is spelled", async () => { + rememberWslHome('Ubuntu', '\\\\wsl.localhost\\Ubuntu\\home\\u') + await applyAgentWorkspaceTrust('claude', '\\\\wsl$\\ubuntu\\home\\u', wsl) + expect(mocks.claudeGrant).not.toHaveBeenCalled() + }) + + it("grants a folder under the guest's known home", async () => { + rememberWslHome('ubuntu', '\\\\wsl.localhost\\Ubuntu\\home\\u') + mocks.claudeGrant.mockResolvedValueOnce('unchanged') + await applyAgentWorkspaceTrust('claude', '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', wsl) + expect(mocks.claudeGrant).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('applyAgentWorkspaceTrust for Claude', () => { + let root: string + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-agent-trust-'))) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('grants in the config file the final spawn env names', async () => { + writeFileSync(join(root, '.claude.json'), '{}') + await applyAgentWorkspaceTrust('claude', root, { + ...local, + env: { CLAUDE_CONFIG_DIR: root } + }) + expect(JSON.parse(readFileSync(join(root, '.claude.json'), 'utf-8'))).toEqual({ + projects: { [root]: { hasTrustDialogAccepted: true } } + }) + }) + + it('gives a local Claude write a short budget, after which Claude asks', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grant = pending() + mocks.claudeGrant.mockReturnValueOnce(grant.promise.then(() => 'granted' as const)) + let settled = false + const claude = applyAgentWorkspaceTrust('claude', root, { + ...local, + env: { CLAUDE_CONFIG_DIR: root } + }).then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(SHORT_AGENT_TRUST_WRITE_DEADLINE_MS - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(2) + await claude + expect(settled).toBe(true) + expect(String(warn.mock.calls[0]?.[0])).toContain('did not settle') + grant.release() + warn.mockRestore() + }) + + it.each(['claude', 'codex', 'cursor', 'copilot', 'qoder', 'antigravity'] as const)( + 'hands an SSH %s launch to the relay instead of writing anything here', + async (preset) => { + await expect( + applyAgentWorkspaceTrust(preset, '/srv/wt', { ...local, connectionId: 'ssh-1' }) + ).resolves.toEqual({ agentWorkspaceTrust: { workspacePath: '/srv/wt' } }) + for (const writer of [ + mocks.codex, + mocks.cursor, + mocks.copilot, + mocks.qoder, + mocks.antigravity, + mocks.claudeGrant + ]) { + expect(writer).not.toHaveBeenCalled() + } + } + ) +}) diff --git a/src/main/agent-workspace-trust.ts b/src/main/agent-workspace-trust.ts new file mode 100644 index 00000000000..58d39b36122 --- /dev/null +++ b/src/main/agent-workspace-trust.ts @@ -0,0 +1,80 @@ +import { homedir } from 'node:os' +import { + AGENT_TRUST_WRITE_DEADLINE_MS, + SHORT_AGENT_TRUST_WRITE_DEADLINE_MS +} from './agent-trust-write-deadline' +import type { AgentTrustPreset } from './agent-trust-presets' +import { resolveLocalClaudeTrustConfig } from './claude/claude-folder-trust-file' +import type { ClaudeRuntimeAuthPreparation } from './claude-accounts/runtime-auth/runtime-auth-types' +import type { AgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request' +import { parseWslUncPath } from '../shared/wsl-paths' +import { applyWorkspaceTrustOnThisHost } from './execution-host-workspace-trust' +import { getLocalCodexTrustConfigFiles } from './codex/codex-home-paths' +import { getCachedWslHome } from './wsl-home-cache' + +/** What a trust writer needs to reach the file the launched agent will read. */ +export type AgentTrustLaunchContext = { + /** The final spawn env, before the host's own process env. */ + env: Record | undefined + /** Managed-account auth prep for a Claude launch; names a WSL guest's config dir. */ + claudeAuth: ClaudeRuntimeAuthPreparation | null + wslDistro: string | null + /** SSH connection that runs the agent; null means this machine. */ + connectionId: string | null +} + +/** Spawn fields the dispatcher asks the caller to forward to the process owner. */ +export type AgentTrustSpawnFields = { + agentWorkspaceTrust?: AgentWorkspaceTrustSpawnRequest +} + +function isWslLaunch(workspacePath: string, context: AgentTrustLaunchContext): boolean { + return ( + Boolean(context.wslDistro) || + context.claudeAuth?.runtime === 'wsl' || + parseWslUncPath(workspacePath) !== null + ) +} + +/** Homes an agent on this machine may read trust under; SSH hosts check their own. */ +function localHomePaths(workspacePath: string, context: AgentTrustLaunchContext) { + const wslWorkspace = parseWslUncPath(workspacePath) + // Why: a WSL guest agent reads trust under the guest's home; an uncached one means write nothing. + return wslWorkspace + ? [getCachedWslHome(wslWorkspace.distro)] + : [homedir(), context.env?.HOME, context.env?.USERPROFILE] +} + +/** + * Pre-trusts `workspacePath` for the agent Orca is about to start, on the host that runs + * it. Never throws and never waits past the preset's deadline: a miss means the agent asks. + */ +export async function applyAgentWorkspaceTrust( + preset: AgentTrustPreset, + workspacePath: string, + context: AgentTrustLaunchContext +): Promise { + if (context.connectionId) { + // Why: the SSH host's relay writes on its own disk, under its own homes and the agent's final env. + return { agentWorkspaceTrust: { workspacePath } } + } + // Why: the other writers target this host's home, which a WSL guest agent never reads. + if (preset !== 'claude' && isWslLaunch(workspacePath, context)) { + return {} + } + await applyWorkspaceTrustOnThisHost(preset, workspacePath, () => ({ + homes: localHomePaths(workspacePath, context), + claudeConfig: () => + resolveLocalClaudeTrustConfig({ + workspacePath, + env: { ...process.env, ...context.env }, + claudeAuth: context.claudeAuth, + wslDistro: context.wslDistro + }), + codexConfigFiles: getLocalCodexTrustConfigFiles, + // Why: Codex queues behind a config lane it shares with Orca's hook installs. + deadlineMs: + preset === 'codex' ? AGENT_TRUST_WRITE_DEADLINE_MS : SHORT_AGENT_TRUST_WRITE_DEADLINE_MS + })) + return {} +} diff --git a/src/main/ai-vault-search/session-search-host-registration.test.ts b/src/main/ai-vault-search/session-search-host-registration.test.ts index e2a31ec4f21..5a535d3e554 100644 --- a/src/main/ai-vault-search/session-search-host-registration.test.ts +++ b/src/main/ai-vault-search/session-search-host-registration.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' @@ -33,8 +32,6 @@ vi.mock('../ai-vault/cached-session-list', async (importOriginal) => ({ localAiVaultScanRoots })) -const ROOT = join(import.meta.dirname, '..', '..', '..') - let harness: SessionSearchIndexerHarness let installed: { apply?(settings: AiVaultSearchSettings): void; dispose(): void } | null @@ -141,26 +138,6 @@ it('registers an in-process service for a host with no scanner child', async () }) }) -// The behavioural tests above prove the installers register; these prove each -// host's boot path reaches one, which no unit of either module can show. -it.each([ - [ - 'desktop and headless serve', - 'src/main/startup/main-process-runtime-service.ts', - 'installChildSessionSearchService' - ], - ['orcad', 'src/main/orcad/orcad-session-search.ts', 'installInProcessSessionSearchService'], - [ - 'the relay daemon', - 'src/relay/relay-runtime-services.ts', - 'installInProcessSessionSearchService' - ] -])('boots %s with a registered session search service', (_host, file, installer) => { - const source = readFileSync(join(ROOT, file), 'utf8') - expect(source).toContain(installer) - expect(source).toMatch(new RegExp(`${installer}\\(\\{`)) -}) - it('disables immediately without root discovery', async () => { const { installChildSessionSearchService, applySessionSearchSettingsChange } = await import('./session-search-enablement') @@ -231,11 +208,3 @@ it('re-applies consent on an in-process host without reinstalling the service', reason: 'disabled' }) }) - -// orcad reaches the index through the deps hook the runtime RPC calls; the wiring is -// what no unit of either module can show. -it('wires orcad consent from the runtime hook to the installed service', () => { - const source = readFileSync(join(ROOT, 'src/main/orcad/orcad-entry.ts'), 'utf8') - expect(source).toContain('applySessionSearchSettings:') - expect(source).toContain('sessionSearch?.apply(next)') -}) diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index bbdd4be2877..c0d3668f1f3 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -100,13 +100,6 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) }) - it('resolves homes only for distros currently reported as running', async () => { - listRunningWslHomeDirsAsync.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) - - await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) - expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) - }) - it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => { hasCachedWslDistros.mockReturnValue(true) getCachedWslDistros.mockReturnValue([]) diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index c1c99bcbf12..a36e0c20a84 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -349,36 +349,6 @@ describe('scanRemoteAiVaultSessions', () => { ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) }) - it('keeps Antigravity SSH discovery to one listing as the session store grows', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - for (let index = 0; index < 40; index++) { - provider.addFile( - `${brainDir}/session-${index}/.system_generated/logs/transcript.jsonl`, - jsonLines([ - { - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - created_at: `2026-07-15T11:39:${String(index).padStart(2, '0')}Z`, - content: `Remote session ${index}` - } - ]), - 100 + index - ) - } - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(40) - expect(provider.readDirPaths.filter((path) => path.startsWith(brainDir))).toEqual([brainDir]) - }) - it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { const provider = new MemoryRemoteProvider() const brainDir = '/home/ada/.gemini/antigravity-cli/brain' diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 922a6af96d7..01fd56c23fc 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -116,17 +116,6 @@ describe('runtime AI Vault session scanner', () => { ]) }) - it('stamps accepted sessions with the requested runtime host', async () => { - const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {}) - - expect(scanResult.sessions).toEqual([ - expect.objectContaining({ - id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl', - executionHostId: 'runtime:env-1' - }) - ]) - }) - it('prepares a resume on the transcript-owning runtime', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts index cacb0dbbda5..7bfcf0615cb 100644 --- a/src/main/ai-vault/session-delete.test.ts +++ b/src/main/ai-vault/session-delete.test.ts @@ -88,20 +88,6 @@ describe('deleteAiVaultSessionFile', () => { expect(trashItemMock).not.toHaveBeenCalled() }) - it('rejects a symlink instead of trashing it (isFile() is false for a symlink under lstat)', async () => { - const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') - lstatMock.mockResolvedValue({ isFile: () => false }) - - const result = await deleteAiVaultSessionFile(baseArgs(filePath)) - - expect(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'unexpected-target-kind' - }) - expect(trashItemMock).not.toHaveBeenCalled() - }) - it('rejects a regular file whose realpath escapes the known roots', async () => { const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') const escaped = join(HOME, 'Documents', 'escaped.json') diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index 484b8c4eb6d..0e63b9de168 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -32,20 +32,6 @@ describe('parseAiVaultListResult', () => { expect(parsed.issues).toEqual([]) }) - it('accepts an all-unknown-agent response as an empty supported session list', () => { - const parsed = parseAiVaultListResult({ - sessions: [ - { ...validSession('future-1'), agent: 'future-agent' }, - { ...validSession('future-2'), agent: 'newer-agent' } - ], - issues: [], - scannedAt: '2026-07-27T00:00:00.000Z' - }) - - expect(parsed.sessions).toEqual([]) - expect(parsed.issues).toEqual([]) - }) - it('keeps known scan issues while silently dropping unknown-agent issues', () => { const parsed = parseAiVaultListResult({ sessions: [], diff --git a/src/main/ai-vault/session-list-results.test.ts b/src/main/ai-vault/session-list-results.test.ts index 781ee65d124..8d1b39f47b6 100644 --- a/src/main/ai-vault/session-list-results.test.ts +++ b/src/main/ai-vault/session-list-results.test.ts @@ -181,15 +181,6 @@ describe('mergeAiVaultListResults', () => { expect(merged.sessions).toHaveLength(1001) }) - it('keeps a per-host scope truncation notice when merging all-host results', () => { - const merged = mergeAiVaultListResults( - [listResult([]), listResult([SCOPE_TRUNCATION])], - undefined - ) - - expect(merged.issues).toEqual([SCOPE_TRUNCATION]) - }) - it('keeps one scope notice per host rather than collapsing them', () => { const otherHost: AiVaultScanIssue = { ...SCOPE_TRUNCATION, executionHostId: 'ssh:build-box' } diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts index ee617a2352d..32328e89837 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.test.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -248,23 +248,6 @@ describe('session parse cache persistence', () => { expect(stats.reused).toBe(0) }) - it('rejects the legacy schema 1 cache after parser semantics changed', async () => { - const root = await makeTempDir() - const cacheFile = join(root, 'session-parse-cache.json') - initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) - const transcript = await writeTranscript(root) - await parseAndPersist(transcript) - - const persisted = JSON.parse(await readFile(cacheFile, 'utf-8')) - persisted.schemaVersion = 1 - await writeFile(cacheFile, JSON.stringify(persisted)) - - simulateRestart(cacheFile) - const stats = await coldParseStats(transcript) - expect(stats.fullParses).toBe(1) - expect(stats.reused).toBe(0) - }) - it('reuses a schema-compatible cache written by a different app version', async () => { const root = await makeTempDir() const cacheFile = join(root, 'session-parse-cache.json') diff --git a/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts b/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts index b2ea6f0d4d2..607d3f6159d 100644 --- a/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts +++ b/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts @@ -155,24 +155,14 @@ describe('agent scan roots from environment overrides', () => { expect(roots[0]).toBe(testCase.absoluteRoot) }) + // A relative root is the actual #13082 failure: it resolves against whichever Orca process + // reads it, so the walk starts somewhere arbitrary and has no depth, entry or time cap. it.each(RELATIVE_VALUES)('falls back to the default root for %j', async (value) => { const roots = await rootDirsFor(testCase.agent, { [testCase.envVar]: value }) expect(roots[0]).toBe(testCase.defaultRoot()) }) - - // A relative root is the actual #13082 failure: it resolves against whichever Orca process - // reads it, so the walk starts somewhere arbitrary and has no depth, entry or time cap. - it.each(RELATIVE_VALUES)('never yields a relative root for %j', async (value) => { - const roots = await rootDirsFor(testCase.agent, { - [testCase.envVar]: value - }) - for (const root of roots) { - expect(root).toBe(join(root)) - expect(root.startsWith('/') || /^[A-Za-z]:[\\/]/.test(root)).toBe(true) - } - }) }) } }) diff --git a/src/main/ai-vault/session-scanner-claude-title.test.ts b/src/main/ai-vault/session-scanner-claude-title.test.ts index 6b83d30d8e4..e1eef0df48a 100644 --- a/src/main/ai-vault/session-scanner-claude-title.test.ts +++ b/src/main/ai-vault/session-scanner-claude-title.test.ts @@ -70,35 +70,4 @@ describe('scanAiVaultSessions Claude title selection', () => { 'User set title' ]) }) - - it('excludes Claude Task subagent transcripts from the session list', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagents-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - const sessionDir = join(roots.claudeProjectsDir, 'project', 'claude-session') - - await writeJsonlFile(join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'), [ - { - type: 'user', - sessionId: 'claude-session', - timestamp: '2026-05-01T10:00:00.000Z', - cwd: '/tmp/claude', - message: { role: 'user', content: 'Parent session prompt' } - } - ]) - await writeJsonlFile(join(sessionDir, 'subagents', 'agent-abc123.jsonl'), [ - { - type: 'user', - sessionId: 'claude-session', - timestamp: '2026-05-01T10:00:05.000Z', - cwd: '/tmp/claude', - message: { role: 'user', content: 'Subagent task prompt' } - } - ]) - - const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.title)).toEqual(['Parent session prompt']) - }) }) diff --git a/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts b/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts index 7eb41e99289..6bd85bc6484 100644 --- a/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts +++ b/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts @@ -42,42 +42,6 @@ describe('readCodexNonUserOrigin', () => { }) }) - it('carries the depth of a nested child instead of flattening it', () => { - const origin = readCodexNonUserOrigin( - spawnedPayload({ - parent_thread_id: 'middle-thread', - depth: 2, - agent_path: '/root/pr_review_pass_1/adversarial_correctness', - agent_nickname: 'Noether', - agent_role: 'explorer' - }) - ) - - expect(origin?.parentage?.depth).toBe(2) - expect(origin?.parentage?.parentThreadId).toBe('middle-thread') - expect(origin?.parentage?.agentRole).toBe('explorer') - }) - - it('keeps the rest of the spawn when the naming path is null', () => { - const origin = readCodexNonUserOrigin( - spawnedPayload({ - parent_thread_id: 'user-thread', - depth: 1, - agent_path: null, - agent_nickname: 'Laplace', - agent_role: 'explorer' - }) - ) - - expect(origin?.parentage).toEqual({ - parentThreadId: 'user-thread', - depth: 1, - agentNickname: 'Laplace', - agentRole: 'explorer', - agentPath: null - }) - }) - it('reads the agent_type spelling of the role, nested and on the payload', () => { // Codex documents `agent_type` as an alias of `agent_role` in both places. expect(readCodexNonUserOrigin(spawnedPayload({ agent_type: 'explorer' }))?.parentage).toEqual({ @@ -236,14 +200,6 @@ describe('readCodexNonUserOrigin', () => { }) }) - it('reads no origin from a user thread', () => { - expect( - readCodexNonUserOrigin({ id: 'user-thread', thread_source: 'user', source: 'cli' }) - ).toBeNull() - expect(readCodexNonUserOrigin({ id: 'user-thread', source: 'vscode' })).toBeNull() - expect(readCodexNonUserOrigin({ id: 'user-thread' })).toBeNull() - }) - it('keeps a thread whose source states no readable tag at all', () => { // A tagged union spells a tag as a bare string or a single-key object, so // none of these is one and none says the thread is not the user's. Reading diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.test.ts b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts index a5aa909bfa1..1c57753c2ff 100644 --- a/src/main/ai-vault/session-scanner-codex-tool-records.test.ts +++ b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts @@ -67,7 +67,6 @@ it.each([false, true])( it.each([ { type: 'add', content: '+ addedneedle' }, - { type: 'delete', content: '+ addedneedle' }, { type: 'update', unified_diff: '+ addedneedle', move_path: null } ])('publishes paginated $type file changes', (change) => { const messages: TranscriptMessage[] = [] diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 03c84f023c6..67624f612bc 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -2,6 +2,7 @@ import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease- import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' import { listStructuredProviderSessionOwnership, type StructuredProviderSessionOwnership @@ -53,7 +54,8 @@ export async function assertLegacyAiVaultResumeCommandAllowed( if (!isPotentialStructuredResumeCommand(command)) { return } - await ensureHost() + // A terminal command is not a chat: with chats refused here there is no ownership to check. + await ensureStructuredAgentSessionHostUnlessRefused(ensureHost) const host = getStructuredAgentSessionHost() if (!host) { return diff --git a/src/main/browser/agent-browser-bridge-command-transport.test.ts b/src/main/browser/agent-browser-bridge-command-transport.test.ts index dd2899469b4..e2bbef16235 100644 --- a/src/main/browser/agent-browser-bridge-command-transport.test.ts +++ b/src/main/browser/agent-browser-bridge-command-transport.test.ts @@ -209,15 +209,6 @@ describe('AgentBrowserBridge', () => { }) }) - it('handles malformed JSON from agent-browser', async () => { - execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { - cb(null, 'not json at all', '') - } - ) - await expect(bridge.snapshot()).rejects.toThrow() - }) - // ── exec passthrough ── it('strips --cdp and --session from exec commands', async () => { diff --git a/src/main/browser/agent-browser-bridge-pointer-input.test.ts b/src/main/browser/agent-browser-bridge-pointer-input.test.ts index 4e8cdf22123..9802dd8c18d 100644 --- a/src/main/browser/agent-browser-bridge-pointer-input.test.ts +++ b/src/main/browser/agent-browser-bridge-pointer-input.test.ts @@ -430,17 +430,4 @@ describe('AgentBrowserBridge coordinate pointer input', () => { expect(wc.focus).toHaveBeenCalled() }) - - it('drops empty command queues after pointer commands finish', async () => { - await bridge.mouseMove(10, 20) - await bridge.mouseWheel(120) - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the bridge's own private queue bookkeeping, mirroring agent-browser-bridge-mouse-input.test.ts. - const internals = bridge as unknown as { - commandQueues: Map - processingQueues: Set - } - expect(internals.commandQueues.size).toBe(0) - expect(internals.processingQueues.size).toBe(0) - }) }) diff --git a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts index 8cb8dbe117a..431cfd6f0be 100644 --- a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts +++ b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts @@ -633,46 +633,6 @@ describe('AgentBrowserBridge', () => { expect(peakRetirements).toBe(4) }) - it('destroys a session that finishes creating during runtime shutdown', async () => { - const commandCalls: string[][] = [] - let releaseStaleClose: (() => void) | null = null - execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { - commandCalls.push(args) - if (args.includes('close') && !releaseStaleClose) { - releaseStaleClose = () => { - cb(null, JSON.stringify({ success: true, data: null }), '') - } - return { kill: vi.fn() } - } - cb(null, JSON.stringify({ success: true, data: null }), '') - return { kill: vi.fn() } - } - ) - - const ensurePromise = ( - bridge as unknown as { - ensureSession: ( - sessionName: string, - browserPageId: string, - webContentsId: number - ) => Promise - } - ).ensureSession('orca-tab-tab-1', 'tab-1', 100) - await vi.waitFor(() => expect(releaseStaleClose).not.toBeNull()) - - const destroyAllPromise = bridge.destroyAllSessions() - releaseStaleClose!() - await ensurePromise - await destroyAllPromise - - const sessions = (bridge as unknown as { sessions: Map }).sessions - const proxy = CdpWsProxyMock.instances[0] as { stop: ReturnType } - expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2) - expect(sessions.size).toBe(0) - expect(proxy.stop).toHaveBeenCalledTimes(1) - }) - // Why: quit awaits destroyAllSessions inside a 20s barrier, so an unbounded close can hold the // window up for the whole deadline when the daemon is wedged (#16367). it('bounds every teardown close well inside the quit barrier', async () => { diff --git a/src/main/browser/agent-browser-bridge-state-commands.ts b/src/main/browser/agent-browser-bridge-state-commands.ts index 4e03055da57..779c6930887 100644 --- a/src/main/browser/agent-browser-bridge-state-commands.ts +++ b/src/main/browser/agent-browser-bridge-state-commands.ts @@ -15,6 +15,7 @@ import type { import { BrowserError } from './cdp-bridge' import { parseShellArgs, stripAgentBrowserTargetArgs } from './agent-browser-bridge-process' import { AgentBrowserBridgeInteractionCommands } from './agent-browser-bridge-interaction-commands' +import { sendGuestCdpCommand } from './guest-cdp-command' export abstract class AgentBrowserBridgeStateCommands extends AgentBrowserBridgeInteractionCommands { // ── Cookie commands ── @@ -101,16 +102,14 @@ export abstract class AgentBrowserBridgeStateCommands extends AgentBrowserBridge } // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + await sendGuestCdpCommand(wc, 'Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: scale, mobile }) // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. - await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( - () => {} - ) + await sendGuestCdpCommand(wc, 'Emulation.setVisibleSize', { width, height }).catch(() => {}) return { width, diff --git a/src/main/browser/agent-browser-bridge-tab-routing.test.ts b/src/main/browser/agent-browser-bridge-tab-routing.test.ts index 103de60f4c7..d4d5af32b83 100644 --- a/src/main/browser/agent-browser-bridge-tab-routing.test.ts +++ b/src/main/browser/agent-browser-bridge-tab-routing.test.ts @@ -109,16 +109,6 @@ describe('AgentBrowserBridge', () => { // ── Worktree filtering ── describe('worktree filtering', () => { - it('returns all tabs when no worktreeId', () => { - const tabs = new Map([ - ['tab-a', 1], - ['tab-b', 2] - ]) - const b = new AgentBrowserBridge(mockBrowserManager(tabs)) - const result = b.tabList() - expect(result.tabs).toHaveLength(2) - }) - it('returns only matching worktree tabs', () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge-test-harness.ts b/src/main/browser/agent-browser-bridge-test-harness.ts index c4c28006e82..34e381838b2 100644 --- a/src/main/browser/agent-browser-bridge-test-harness.ts +++ b/src/main/browser/agent-browser-bridge-test-harness.ts @@ -56,6 +56,7 @@ export type MockWebContents = { on: Mock<(event: string, listener: MockEmitterListener) => void> removeListener: Mock<(event: string, listener: MockEmitterListener) => void> isDestroyed: () => boolean + isCrashed: () => boolean invalidate: Mock<() => void> focus: Mock<() => void> debugger: MockWebContentsDebugger @@ -78,6 +79,7 @@ export function mockWebContents( on: vi.fn(), removeListener: vi.fn(), isDestroyed: () => false, + isCrashed: () => false, invalidate: vi.fn(), focus: vi.fn(), debugger: { 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 f083d11067c..2a772b04b31 100644 --- a/src/main/browser/agent-browser-bridge-text-input.test.ts +++ b/src/main/browser/agent-browser-bridge-text-input.test.ts @@ -57,7 +57,6 @@ import { } from './agent-browser-bridge' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, - CLIPBOARD_TEXT_WRITE_MAX_BYTES, CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR } from '../../shared/clipboard-text' import { @@ -247,17 +246,6 @@ describe('AgentBrowserBridge', () => { bridge.setActiveTab(100) }) - it('rejects oversized browser clipboard writes before spawning agent-browser', async () => { - const secret = 'browser-clipboard-secret' - succeedWith({ ok: true }) - - await expect( - bridge.clipboardWrite(secret + 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)) - ).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) - - expect(execFileMock).not.toHaveBeenCalled() - }) - it('rejects browser clipboard writes that exceed the safe agent-browser argument size', async () => { succeedWith({ ok: true }) @@ -268,21 +256,6 @@ describe('AgentBrowserBridge', () => { expect(execFileMock).not.toHaveBeenCalled() }) - it('builds valid fill eval JavaScript for multiline values', async () => { - succeedWith({ ok: true }) - - await bridge.fill('@textarea', "line one\nline two with 'quote' and \\ slash") - - const evalCall = execFileMock.mock.calls.find((call: unknown[]) => - (call[1] as string[]).includes('eval') - ) - expect(evalCall).toBeDefined() - const args = evalCall![1] as string[] - 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 () => { succeedForContentEditable() const environment = createContentEditableEvalEnvironment('existing text') diff --git a/src/main/browser/browser-client-download-relay.test.ts b/src/main/browser/browser-client-download-relay.test.ts index 7a307665f9f..314485ba5a4 100644 --- a/src/main/browser/browser-client-download-relay.test.ts +++ b/src/main/browser/browser-client-download-relay.test.ts @@ -117,20 +117,6 @@ describe('BrowserClientDownloadRelay', () => { expect(removed).toContain(path.dirname(route.stagingPath)) }) - it('sweeps the staging root left behind by an earlier run', () => { - const { filesystem, sweptSync } = memoryFilesystem(Buffer.alloc(0)) - - new BrowserClientDownloadRelay({ - stagingRoot: '/tmp/staging', - hostLabel: 'build-box', - transport: negotiatedTransport(() => ({ accepted: true })), - resolvePage: () => page, - filesystem - }) - - expect(sweptSync).toEqual(['/tmp/staging']) - }) - it('removes the per-transfer staging directory when the transfer is aborted', async () => { const { filesystem, removed } = memoryFilesystem(Buffer.from('abc')) const relay = new BrowserClientDownloadRelay({ diff --git a/src/main/browser/browser-client-host-authority-replacement.test.ts b/src/main/browser/browser-client-host-authority-replacement.test.ts index 9068f26f30f..8988fdb750b 100644 --- a/src/main/browser/browser-client-host-authority-replacement.test.ts +++ b/src/main/browser/browser-client-host-authority-replacement.test.ts @@ -1,10 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browser-client-host-protocol' import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { - BrowserClientHostAuthorityReplacementWait, - DEFAULT_AUTHORITY_REPLACEMENT_GRACE_MS -} from './browser-client-host-authority-replacement-wait' +import { BrowserClientHostAuthorityReplacementWait } from './browser-client-host-authority-replacement-wait' function errorWithCode(message: string, code: unknown): Error { return Object.assign(new Error(message), { code }) @@ -35,12 +32,6 @@ describe('browser client host authority replacement', () => { expect(isBrowserClientHostAuthorityReplaced(unrelated)).toBe(false) }) - it('rejects a codeless host error with an unrelated message', () => { - expect(isBrowserClientHostAuthorityReplaced(new Error('browser host process exited'))).toBe( - false - ) - }) - it('rejects non-Error rejections, including a bare object carrying the mismatch code', () => { expect(isBrowserClientHostAuthorityReplaced(undefined)).toBe(false) expect(isBrowserClientHostAuthorityReplaced(null)).toBe(false) @@ -154,20 +145,4 @@ describe('BrowserClientHostAuthorityReplacementWait', () => { vi.advanceTimersByTime(1_000) expect(afterExpiry).toHaveBeenCalledOnce() }) - - it('tolerates cancelling when nothing is armed', () => { - const wait = new BrowserClientHostAuthorityReplacementWait(1_000) - - expect(() => { - wait.cancel() - wait.cancel() - }).not.toThrow() - expect(wait.armed).toBe(false) - }) - - // The grace has to outlast a real restart, or the environment is torn down before the replacement - // runtime finishes coming up. - it('defaults the grace to 45 seconds', () => { - expect(DEFAULT_AUTHORITY_REPLACEMENT_GRACE_MS).toBe(45_000) - }) }) diff --git a/src/main/browser/browser-client-host-published-url-wiring.test.ts b/src/main/browser/browser-client-host-published-url-wiring.test.ts index a15977b3017..e5dc069b5f5 100644 --- a/src/main/browser/browser-client-host-published-url-wiring.test.ts +++ b/src/main/browser/browser-client-host-published-url-wiring.test.ts @@ -144,16 +144,4 @@ describe('published-url observation wiring', () => { ).resolves.toBeInstanceOf(Error) expect(recordedUrlParams).toEqual([]) }) - - it('does not observe publishes aimed at another environment', async () => { - const { options, publishBrowserClientPageMetadata } = await startHost() - options.createExecutor( - { orcaProfileId: 'profile-a' }, - { retainNetworkRoute: () => Promise.resolve({}), onPageUnavailable: () => {} } - ) - - await publishBrowserClientPageMetadata('environment-b', metadata).catch(() => undefined) - - expect(recordedUrlParams).toEqual([]) - }) }) diff --git a/src/main/browser/browser-client-network-route-registry.test.ts b/src/main/browser/browser-client-network-route-registry.test.ts index cda0bf489de..044134ab622 100644 --- a/src/main/browser/browser-client-network-route-registry.test.ts +++ b/src/main/browser/browser-client-network-route-registry.test.ts @@ -115,30 +115,6 @@ describe('BrowserClientNetworkRouteRegistry', () => { expect(routeFactory).not.toHaveBeenCalled() }) - it('releases an aborted startup without admitting a handle', async () => { - let resolveStart = (_address: { host: string; port: number }): void => {} - const route = createRoute( - new Promise((resolve) => { - resolveStart = resolve - }) - ) - const registry = new BrowserClientNetworkRouteRegistry({ - authority, - authorityStorageKey, - createRoute: () => route - }) - const controller = new AbortController() - const retaining = registry.retain( - browserNetworkExecutionHostKey({ kind: 'native', runtimeId: 'runtime-a', revision: 1 }), - controller.signal - ) - - controller.abort() - await expect(retaining).rejects.toThrow('browser_client_network_route_aborted') - resolveStart({ host: '127.0.0.1', port: 43123 }) - await vi.waitFor(() => expect(route.close).toHaveBeenCalledOnce()) - }) - it('closes every retained route and rejects later admission', async () => { const firstRoute = createRoute() const secondRoute = createRoute() diff --git a/src/main/browser/browser-client-page-command-executor-fencing.test.ts b/src/main/browser/browser-client-page-command-executor-fencing.test.ts index bd2664b71fc..a90f2810dac 100644 --- a/src/main/browser/browser-client-page-command-executor-fencing.test.ts +++ b/src/main/browser/browser-client-page-command-executor-fencing.test.ts @@ -208,28 +208,4 @@ describe('BrowserClientPageCommandExecutor authority fencing', () => { errorCode: 'browser_client_page_executor_closed' }) }) - - it('prevents an in-flight creation from granting navigation after fencing', async () => { - const { dependencies, executor, order, route } = createHarness() - let resolveRoute = (_route: typeof route): void => {} - dependencies.retainNetworkRoute.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRoute = resolve - }) - ) - const creating = executor.handle(createCommand('createPage'), new AbortController().signal) - await Promise.resolve() - - executor.fenceNavigation() - resolveRoute(route) - - await expect(creating).resolves.toEqual({ - status: 'failed', - errorCode: 'browser_client_page_executor_closed' - }) - expect(dependencies.routeWebContents.grantNavigation).not.toHaveBeenCalled() - expect(order).toEqual(['release-route']) - await executor.close() - }) }) diff --git a/src/main/browser/browser-client-page-metadata-transport.test.ts b/src/main/browser/browser-client-page-metadata-transport.test.ts index 238afa9c664..907345172b4 100644 --- a/src/main/browser/browser-client-page-metadata-transport.test.ts +++ b/src/main/browser/browser-client-page-metadata-transport.test.ts @@ -188,24 +188,4 @@ describe('published-url observation', () => { await expect(transport.publish(PARAMS)).rejects.toBeInstanceOf(RemoteRuntimeClientError) expect(observeCurrentUrl).toHaveBeenCalledWith(PARAMS) }) - - it('observes params the schema will reject rather than pre-filtering them', async () => { - const observeCurrentUrl = vi.fn() - const transport = new BrowserClientPageMetadataTransport(observeCurrentUrl) - transport.bind({ - sendPageMetadataRequest: vi.fn().mockResolvedValue(answered({ accepted: true })) - }) - - await transport.publish({ nonsense: true }) - - expect(observeCurrentUrl).toHaveBeenCalledWith({ nonsense: true }) - }) - - it('publishes normally when no observer was supplied', async () => { - const sendPageMetadataRequest = vi.fn().mockResolvedValue(answered({ accepted: true })) - const transport = new BrowserClientPageMetadataTransport() - transport.bind({ sendPageMetadataRequest }) - - await expect(transport.publish(PARAMS)).resolves.toEqual({ accepted: true }) - }) }) diff --git a/src/main/browser/browser-client-page-published-url.test.ts b/src/main/browser/browser-client-page-published-url.test.ts index ca4d5681e5c..37a02b8dc4e 100644 --- a/src/main/browser/browser-client-page-published-url.test.ts +++ b/src/main/browser/browser-client-page-published-url.test.ts @@ -85,21 +85,6 @@ describe('recordBrowserClientPagePublishedUrl', () => { expect(pages.get(PAGE_ID)?.inventory.currentUrl).toBe('https://remote.example/opened-at') }) - it('overwrites the recorded url when a newer navigation arrives', () => { - const pages = tracked(inventory({ currentUrl: 'https://remote.example/opened-at' })) - - recordBrowserClientPagePublishedUrl( - pages, - metadata({ revision: 2, url: 'https://a.example/1' }) - ) - recordBrowserClientPagePublishedUrl( - pages, - metadata({ revision: 3, url: 'https://a.example/2' }) - ) - - expect(pages.get(PAGE_ID)?.inventory.currentUrl).toBe('https://a.example/2') - }) - it('stores a url exactly at the inventory url cap', () => { const pages = tracked() const url = `https://remote.example/${'a'.repeat( diff --git a/src/main/browser/browser-client-page-renderer-bridge.test.ts b/src/main/browser/browser-client-page-renderer-bridge.test.ts index 59591e1ceab..bce4d98e656 100644 --- a/src/main/browser/browser-client-page-renderer-bridge.test.ts +++ b/src/main/browser/browser-client-page-renderer-bridge.test.ts @@ -212,27 +212,6 @@ describe('BrowserClientPageRendererBridgeRegistry', () => { await expect(retired).resolves.toBeUndefined() }) - it('times out exactly once and releases admission for later work', async () => { - vi.useFakeTimers() - try { - const harness = createHarness({ maxPending: 1, timeoutMs: 25 }) - const renderer = harness.registry.attachRenderer(harness.endpoint) - const timedOut = renderer.mountPage(page, new AbortController().signal) - const timedOutExpectation = expect(timedOut).rejects.toThrow( - 'browser_client_page_renderer_request_timeout' - ) - - await vi.advanceTimersByTimeAsync(25) - await timedOutExpectation - - const replacement = renderer.mountPage(page, new AbortController().signal) - completeMount(harness) - await expect(replacement).resolves.toEqual({ webContentsId: 91 }) - } finally { - vi.useRealTimers() - } - }) - it('ignores a late rekey reply after timeout and admits an exact replacement', async () => { vi.useFakeTimers() try { diff --git a/src/main/browser/browser-client-upload-command.test.ts b/src/main/browser/browser-client-upload-command.test.ts index d431129f0a2..70b132aa59e 100644 --- a/src/main/browser/browser-client-upload-command.test.ts +++ b/src/main/browser/browser-client-upload-command.test.ts @@ -164,19 +164,4 @@ describe('executeBrowserClientUploadCommand', () => { // Why: the retained record is the only handle a later page release has on the directory. expect(staging.activeStagingCount()).toBe(1) }) - - it('rejects a params payload whose files are not remote path strings', async () => { - const staging = new BrowserClientUploadStaging(stagingRoot) - const { transport } = transportReturning(new Map()) - - await expect( - executeBrowserClientUploadCommand({ - event: uploadEvent([{ path: 'a.txt' }]), - params: { element: '#f', files: [{ path: 'a.txt' }] }, - fileChannel: transport, - staging, - run: async () => undefined - }) - ).rejects.toThrow('browser_client_upload_files_required') - }) }) diff --git a/src/main/browser/browser-client-upload-transfer.test.ts b/src/main/browser/browser-client-upload-transfer.test.ts index 84bb649abd3..fdc129618e8 100644 --- a/src/main/browser/browser-client-upload-transfer.test.ts +++ b/src/main/browser/browser-client-upload-transfer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { BrowserClientHostCommandEvent } from '../../shared/browser-client-host-protocol' import { @@ -102,38 +102,3 @@ describe('readBrowserClientUploadPaths', () => { ) }) }) - -it.each([0, 1, 128 * 1024])( - 'avoids recopying 16 single-chunk uploads of %i bytes', - async (size) => { - const source = Buffer.alloc(size, 171) - const response = { - contentBase64: source.toString('base64'), - bytesRead: size, - totalBytes: size, - eof: true - } - const remotePaths = Array.from({ length: 16 }, (_, i) => `file-${i}.bin`) - const request = vi.fn(async () => response) - const concat = vi.spyOn(Buffer, 'concat') - let copies = 0 - let files: Awaited> - try { - files = await fetchBrowserClientUploadFiles({ request, event, remotePaths }) - copies = concat.mock.calls.length - } finally { - concat.mockRestore() - } - expect(copies).toBe(0) - expect(request).toHaveBeenCalledTimes(16) - expect(files.map((file) => file.remotePath)).toEqual(remotePaths) - for (const file of files) { - expect(file.contents).toEqual(source) - } - if (size > 0) { - files[0].contents[0] = 0 - expect(files[1].contents[0]).toBe(171) - expect(source[0]).toBe(171) - } - } -) diff --git a/src/main/browser/browser-cookie-clear-preserve.test.ts b/src/main/browser/browser-cookie-clear-preserve.test.ts index e082c2eff6f..0cacbfd5dc6 100644 --- a/src/main/browser/browser-cookie-clear-preserve.test.ts +++ b/src/main/browser/browser-cookie-clear-preserve.test.ts @@ -1,10 +1,6 @@ import type { Cookie } from 'electron' import { describe, expect, it, vi } from 'vitest' -import { - removeTransplantableCookies, - type CookieClearIdentity, - type CookieClearSession -} from './browser-cookie-import-clear' +import { removeTransplantableCookies, type CookieClearSession } from './browser-cookie-import-clear' import { importedDomainScope } from './browser-cookie-import-policy' /** @@ -56,27 +52,6 @@ const coordinatesOf = (calls: readonly [string, string][]): string[] => calls.map(([url, name]) => `${url}|${name}`) describe('removeTransplantableCookies — preserved families on a POPULATED jar', () => { - it('clears every in-scope domain through per-coordinate removals when nothing is preserved', async () => { - const target = jar([ - cookie('.mixed.example', 'live-session'), - cookie('.other.example', 'stale') - ]) - - await removeTransplantableCookies( - target.session, - new Set(), - importedDomainScope(['mixed.example', 'other.example']) - ) - - expect(target.names()).toEqual([]) - // Why (STA-4797): the bulk clearData shortcut is gone — an import with nothing preserved still - // clears one coordinate at a time, drawn from the frozen plan the snapshot is taken from. - expect(coordinatesOf(target.removeMock.mock.calls)).toEqual([ - 'https://mixed.example/|live-session', - 'https://other.example/|stale' - ]) - }) - it('leaves a preserved family untouched while clearing everything else', async () => { const target = jar([ cookie('.mixed.example', 'apex-session'), @@ -121,55 +96,6 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' expect(snapshotted).toEqual(removed) }) - it('never submits a preserved coordinate to a removal', async () => { - const target = jar([ - cookie('.mixed.example', 'apex-session'), - cookie('sub.mixed.example', 'sub-session'), - cookie('.other.example', 'stale') - ]) - - await removeTransplantableCookies( - target.session, - new Set(['mixed.example']), - importedDomainScope(['mixed.example', 'sub.mixed.example', 'other.example']) - ) - - const removedNames = target.removeMock.mock.calls.map((call) => call[1]) - expect(removedNames).toEqual(['stale']) - }) - - it('never submits a preserved coordinate to the restore set either', async () => { - // Why: the CDP snapshot is taken FROM the removal plan, so filtering the plan filters the - // restore set with it. That is what keeps a preserved cookie out of every mutation — including - // the unconditional Network.setCookie that rollback performs. - const snapshotted: string[] = [] - const target = jar([ - cookie('.mixed.example', 'live-session'), - cookie('.other.example', 'stale') - ]) - const session = { - ...target.session, - snapshotClearIdentities: async (items: { cookie: Cookie; url: string }[]) => { - snapshotted.push(...items.map((i) => i.cookie.name)) - return items.map(({ cookie: c, url }) => ({ - url, - name: c.name, - value: c.value, - domain: c.domain, - path: c.path - })) as CookieClearIdentity[] - } - } as unknown as CookieClearSession - - await removeTransplantableCookies( - session, - new Set(['mixed.example']), - importedDomainScope(['mixed.example', 'other.example']) - ) - - expect(snapshotted).toEqual(['stale']) - }) - it('preserves a family named by an IPv4 literal', async () => { // 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 @@ -197,18 +123,6 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' expect(target.names()).toEqual(['dev-session']) }) - it('does not preserve a different family that merely shares a suffix', async () => { - const target = jar([cookie('.kept.example', 'kept'), cookie('.other.example', 'stale')]) - - await removeTransplantableCookies( - target.session, - new Set(['kept.example']), - importedDomainScope(['kept.example', 'other.example']) - ) - - expect(target.names()).toEqual(['kept']) - }) - it('keeps skip-path removals at concurrency eight', async () => { let releaseRemovals: (() => void) | undefined const removalsReleased = new Promise((resolve) => { diff --git a/src/main/browser/browser-cookie-decryption.test.ts b/src/main/browser/browser-cookie-decryption.test.ts index b16a9ed4b97..33ed500cc51 100644 --- a/src/main/browser/browser-cookie-decryption.test.ts +++ b/src/main/browser/browser-cookie-decryption.test.ts @@ -26,13 +26,6 @@ function encryptGcm(plaintext: Buffer): Buffer { } describe('decryptCookieValueRaw host-key hash prefix', () => { - it('strips the hash from a non-empty value', () => { - const plaintext = Buffer.concat([hostKeyHash(HOST_KEY), Buffer.from('session-value')]) - expect(decryptCookieValueRaw(encryptCbc(plaintext), cbcKeyResult, HOST_KEY)).toEqual( - Buffer.from('session-value') - ) - }) - it('decrypts an empty value whose plaintext is only the hash', () => { const encrypted = encryptCbc(hostKeyHash(HOST_KEY)) expect(decryptCookieValueRaw(encrypted, cbcKeyResult, HOST_KEY)).toEqual(Buffer.alloc(0)) diff --git a/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts deleted file mode 100644 index 0109269248b..00000000000 --- a/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isAppBoundEncryptedCookie } from './browser-cookie-import' - -function encryptedCookie(versionPrefix: string): Buffer { - return Buffer.concat([Buffer.from(versionPrefix), Buffer.from([0xde, 0xad, 0xbe, 0xef])]) -} - -describe('isAppBoundEncryptedCookie', () => { - it('detects the v20 app-bound prefix', () => { - expect(isAppBoundEncryptedCookie(encryptedCookie('v20'))).toBe(true) - }) - - it.each(['v10', 'v11'])('leaves the decryptable %s prefix alone', (prefix) => { - expect(isAppBoundEncryptedCookie(encryptedCookie(prefix))).toBe(false) - }) - - it('does not treat a plaintext value as app-bound', () => { - expect(isAppBoundEncryptedCookie(Buffer.from('session=abc123'))).toBe(false) - }) - - it.each([ - ['empty', Buffer.alloc(0)], - ['shorter than the prefix', Buffer.from('v2')] - ])('handles a buffer %s without throwing', (_label, buffer) => { - expect(isAppBoundEncryptedCookie(buffer)).toBe(false) - }) -}) diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts index ee73dba1ed2..9c1e7351f08 100644 --- a/src/main/browser/browser-cookie-import-google-exclusion.test.ts +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -175,25 +175,6 @@ describe('file import excludes the Google cookie family', () => { '.linear.app' ]) }) - - it('leaves Google cookies from an older import in place too', async () => { - cookiesGetMock.mockResolvedValue([existingCookie('.google.com', 'SAPISID')]) - - const result = await importCookiesFromFile( - writeCookies([{ domain: '.google.com', name: 'SAPISID', value: 'newer', secure: true }]), - 'persist:test' - ) - - expect(result.ok && result.summary).toMatchObject({ - totalCookies: 1, - importedCookies: 0, - skippedCookies: 1, - googleCookiesSkipped: 1, - domains: [] - }) - expect(cookiesRemoveMock).not.toHaveBeenCalled() - expect(cookiesSetMock).not.toHaveBeenCalled() - }) }) describe('native Chromium import excludes the Google cookie family', () => { @@ -296,32 +277,6 @@ describe('native Chromium import excludes the Google cookie family', () => { expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['session']) }) - it('does not request an encryption key for excluded Google rows', async () => { - const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') - createChromiumCookieTestDatabase(sourceCookiesPath, [ - { - domain: '.google.com', - name: 'SID', - value: '', - encryptedValue: Buffer.from('v10-invalid') - } - ]).close() - seedTarget([]) - - const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') - - expect(result.ok && result.summary).toEqual({ - totalCookies: 1, - importedCookies: 0, - skippedCookies: 1, - googleCookiesSkipped: 1, - domains: [] - }) - expect(execFileSyncMock).not.toHaveBeenCalled() - expect(clearDataMock).not.toHaveBeenCalled() - expect(cookiesSetMock).not.toHaveBeenCalled() - }) - it('keeps the live Google rows in the staged restart-fallback database', async () => { const sourceCookiesPath = seedSource([ { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, diff --git a/src/main/browser/browser-cookie-import-partition-fidelity.test.ts b/src/main/browser/browser-cookie-import-partition-fidelity.test.ts index f6fe9cedf6d..4e58017a417 100644 --- a/src/main/browser/browser-cookie-import-partition-fidelity.test.ts +++ b/src/main/browser/browser-cookie-import-partition-fidelity.test.ts @@ -184,44 +184,6 @@ describe('validated import partition fidelity', () => { expect(result.ok && result.summary.domains).toEqual(['plain.example']) }) - it('does not replace existing cookies for a domain whose only source cookie is skipped', async () => { - const remove = vi.fn().mockResolvedValue(undefined) - sessionFromPartitionMock.mockReturnValue({ - cookies: { - get: vi.fn().mockResolvedValue([ - { - name: 'existing-session', - value: 'still-valid', - domain: '.app.example', - path: '/', - secure: true, - httpOnly: true, - hostOnly: false, - session: true, - sameSite: 'lax' - } - ]), - remove, - set: unreachableCookieSet - } - }) - const filePath = writeCookieFile([ - { - domain: '.app.example', - name: 'chips-auth', - value: 'keep-me', - secure: true, - partitionKey: { topLevelSite: 'https://top.example' } - } - ]) - - const result = await importCookiesFromFile(filePath, 'persist:test') - - expect(result.ok && result.summary.partitionSkippedCookies).toBe(1) - expect(cookieWriteMock).not.toHaveBeenCalled() - expect(remove).not.toHaveBeenCalled() - }) - it('preserves a populated family and creates no staged replay for an opaque JSON partition', async () => { const targetJar = [ { diff --git a/src/main/browser/browser-cookie-import-plan-writes.test.ts b/src/main/browser/browser-cookie-import-plan-writes.test.ts index fc2896e79a8..4333a046688 100644 --- a/src/main/browser/browser-cookie-import-plan-writes.test.ts +++ b/src/main/browser/browser-cookie-import-plan-writes.test.ts @@ -89,16 +89,6 @@ describe('planImportWrites', () => { expect([...plan.skippedFamilies]).toEqual(['mixed.example']) }) - it('does not suppress a different family that merely shares a public suffix', () => { - const plan = planImportWrites([ - cookie('.skipped.example', 'unreadable', UNREADABLE), - cookie('.kept.example', 'kept', READABLE) - ]) - - expect(names(plan.writes)).toEqual(['kept']) - expect([...plan.skippedFamilies]).toEqual(['skipped.example']) - }) - it('flags a skip whose family cannot be named so the caller can refuse before mutating', () => { // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal plan, and // clearing a family we cannot protect is exactly the P0. Refusal is the caller's job; the @@ -115,17 +105,4 @@ describe('planImportWrites', () => { expect(plan.hasUnrepresentableSkip).toBe(false) expect([...plan.skippedFamilies]).toEqual(['mixed.example']) }) - - it('counts every suppressed cookie as skipped so the summary still adds up', () => { - const rows = [ - cookie('.mixed.example', 'readable-sibling', READABLE), - cookie('sub.mixed.example', 'unreadable', UNREADABLE), - cookie('.other.example', 'unrelated', READABLE) - ] - - const plan = planImportWrites(rows) - - // totalCookies === importedCookies + skippedCookies, as an identity rather than a field. - expect(plan.writes.length + plan.skips.length).toBe(rows.length) - }) }) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 10929761731..d50789b2e2f 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -17,7 +17,6 @@ import { buildChromiumCookieInsertParams as buildInsertParams, type ChromiumCookieColumnInfo } from './browser-cookie-sqlite' -import { isAppBoundEncryptedCookie as isAppBoundCookie } from './browser-cookie-decryption' import { summarizeCookieImportError as summarizeError } from './browser-cookie-import-diagnostics' export type { BrowserProfile, DetectedBrowser, CookieImportOptions, ChromiumCookieColumnInfo } @@ -53,10 +52,6 @@ export function buildChromiumCookieInsertParams( return buildInsertParams(targetColumns, sourceRow, decryptedValue) } -export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { - return isAppBoundCookie(encryptedBuffer) -} - export async function importCookiesFromBrowser( browser: DetectedBrowser, targetPartition: string, diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts index a87c7e73e08..4bd30d4dc92 100644 --- a/src/main/browser/browser-manager-bindings.ts +++ b/src/main/browser/browser-manager-bindings.ts @@ -78,7 +78,7 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab { resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), isViewportPresetActive: () => { const state = this.viewportPresetByTabId.get(browserTabId) - return state?.guestWebContentsId === guest.id && state.override !== null + return state?.guestWebContentsId === guest.id && state.requested !== null }, canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), onViewportWheelConsumed: (deltaX, deltaY) => diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts index 9c99175096f..270df87a9a8 100644 --- a/src/main/browser/browser-manager-guest-policy.ts +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -26,7 +26,7 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } - const disposeDetachTracking = this.trackDebuggerDetachForUserAgentOverride(guest) + const disposeDetachTracking = this.trackDebuggerDetachForCdpOverrides(guest) // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) const disposePopupPolicy = this.installGuestPopupPolicy(guest, !inheritedOwnerContext) diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index d96d41e79be..1911e9c92b6 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -66,10 +66,10 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of its // own, so inheriting the owner tab's preset would pair a mobile UA with a desktop viewport. - protected hasMobileViewportPreset(guestWebContentsId: number): boolean { + protected hasMobileViewportApplied(guestWebContentsId: number): boolean { const browserPageId = this.tabIdByWebContentsId.get(guestWebContentsId) const preset = browserPageId ? this.viewportPresetByTabId.get(browserPageId) : undefined - return preset?.guestWebContentsId === guestWebContentsId && preset.override?.mobile === true + return preset?.guestWebContentsId === guestWebContentsId && preset.applied?.mobile === true } /** @@ -108,7 +108,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility private resolveGuestTabIdentity(guest: Electron.WebContents, url: string): BrowserTabIdentity { return resolveBrowserTabIdentity({ url, - mobile: this.hasMobileViewportPreset(guest.id), + mobile: this.hasMobileViewportApplied(guest.id), processIdentity: getBrowserProcessUserAgentIdentity() }) } diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index da56552d6c4..411c67d6c37 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -188,11 +188,15 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt this.settingsResolver = resolver } - // Why: a debugger detach clears every CDP override Chromium holds, including the UA override, so - // the confirmed-override record must be dropped or navigation believes an override still stands. - protected trackDebuggerDetachForUserAgentOverride(guest: Electron.WebContents): () => void { + // Why: a debugger detach clears every CDP override Chromium holds, including the UA override and + // device metrics, so both records must be dropped or navigation believes they still stand. + protected trackDebuggerDetachForCdpOverrides(guest: Electron.WebContents): () => void { const onDetach = (): void => { this.cdpUserAgentOverrideStateByGuestId.delete(guest.id) + const browserTabId = this.tabIdByWebContentsId.get(guest.id) + if (browserTabId) { + this.recordAppliedViewportOverride(browserTabId, guest.id, null) + } } try { guest.debugger.on('detach', onDetach) diff --git a/src/main/browser/browser-manager-tab-identity.test.ts b/src/main/browser/browser-manager-tab-identity.test.ts index d8678a6e2cd..d930f1d8ae1 100644 --- a/src/main/browser/browser-manager-tab-identity.test.ts +++ b/src/main/browser/browser-manager-tab-identity.test.ts @@ -107,6 +107,29 @@ function failLoad(url: string): void { didFailLoad(null, -102, 'ERR_CONNECTION_REFUSED', url, true) } +function failDeviceMetrics(handle: ViewportGuestHandle): void { + handle.debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setDeviceMetricsOverride' || + method === 'Emulation.clearDeviceMetricsOverride' + ? Promise.reject(new Error('Inspected target navigated or closed')) + : Promise.resolve(undefined) + ) +} + +function debuggerDetachHandler(handle: ViewportGuestHandle): () => void { + const debuggerApi = handle.guest.debugger + const on = + debuggerApi && typeof debuggerApi === 'object' && 'on' in debuggerApi ? debuggerApi.on : null + if (!vi.isMockFunction(on)) { + throw new Error('Expected a mocked debugger.on') + } + const handler: unknown = on.mock.calls.findLast(([event]) => event === 'detach')?.[1] + if (typeof handler !== 'function') { + throw new Error('Expected a debugger detach handler') + } + return () => handler() +} + async function observe( { id, handle }: { id: number; handle: ViewportGuestHandle }, url: string @@ -255,21 +278,101 @@ describe('tab identity ownership', () => { ) }) - it('still presents the requested mobile identity when device metrics fail', async () => { + // Why: identity follows the viewport Chromium holds, so a phone identity never stands on a desktop + // viewport. The renderer re-sends the preset on the next page load, which heals the tab. + it('keeps the process identity when a mobile preset cannot apply its device metrics', async () => { mocks.processUserAgentMode = 'clean' mocks.processUserAgent = GUEST_CLEAN_UA vi.spyOn(console, 'warn').mockImplementation(() => {}) const opened = openTab(ORDINARY_URL) - opened.handle.debuggerSendCommand.mockImplementation((method: string) => - method === 'Emulation.setDeviceMetricsOverride' - ? Promise.reject(new Error('metrics failed')) - : Promise.resolve(undefined) - ) + failDeviceMetrics(opened.handle) await expect(browserManager.setViewportOverride(opened.tab, PRESETS.mobile)).resolves.toBe( false ) - expect((await observe(opened, ORDINARY_URL)).presented).toContain('iPhone') + const failed = await observe(opened, ORDINARY_URL) + expect(failed.presented).toBe(GUEST_CLEAN_UA) + expect(failed.standingOverride).toBeNull() + expect(failed.requestIdentity).toEqual({ kind: 'process', userAgent: GUEST_CLEAN_UA }) + navigate('https://example.org/') + expect((await observe(opened, 'https://example.org/')).presented).toBe(GUEST_CLEAN_UA) + + opened.handle.debuggerSendCommand.mockResolvedValue(undefined) + await expect(browserManager.setViewportOverride(opened.tab, PRESETS.mobile)).resolves.toBe(true) + expect((await observe(opened, 'https://example.org/')).requestIdentity.kind).toBe('mobile') + }) + + it.each(['desktop', 'none'] as const)( + 'keeps the mobile identity while the phone viewport stands when switching to %s fails', + async (next) => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const opened = openTab(ORDINARY_URL) + await browserManager.setViewportOverride(opened.tab, PRESETS.mobile) + failDeviceMetrics(opened.handle) + + await expect(browserManager.setViewportOverride(opened.tab, PRESETS[next])).resolves.toBe( + false + ) + const failed = await observe(opened, ORDINARY_URL) + expect(failed.presented).toContain('iPhone') + expect(failed.requestIdentity.kind).toBe('mobile') + navigate('https://example.org/') + const reloaded = await observe(opened, 'https://example.org/') + expect(reloaded.presented).toContain('iPhone') + expect(reloaded.requestIdentity.kind).toBe('mobile') + } + ) + + // Why: a detach clears the device metrics along with the UA override, so the next page must not + // reinstall a phone identity on the desktop viewport that remains. + it('drops the mobile identity once a debugger detach clears the device metrics', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + const opened = openTab(ORDINARY_URL) + await browserManager.setViewportOverride(opened.tab, PRESETS.mobile) + expect(opened.handle.presentedUserAgent()).toContain('iPhone') + + debuggerDetachHandler(opened.handle)() + opened.handle.debuggerSendCommand.mockClear() + navigate('https://example.org/') + const observed = await observe(opened, 'https://example.org/') + expect(opened.handle.debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + expect(observed.requestIdentity).toEqual({ kind: 'process', userAgent: GUEST_CLEAN_UA }) + }) + + it('does not let a mid-apply debugger detach leave a mobile identity to reinstall', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const opened = openTab(ORDINARY_URL) + const detach = debuggerDetachHandler(opened.handle) + opened.handle.debuggerSendCommand.mockImplementation((method: string) => { + if (method !== 'Emulation.setTouchEmulationEnabled') { + return Promise.resolve(undefined) + } + opened.handle.debuggerIsAttached.mockReturnValue(false) + detach() + return Promise.reject(new Error('Debugger is not attached')) + }) + + await expect(browserManager.setViewportOverride(opened.tab, PRESETS.mobile)).resolves.toBe( + false + ) + // Another CDP client (the agent bridge) re-attaches before the preset is sent again. + opened.handle.debuggerIsAttached.mockReturnValue(true) + opened.handle.debuggerSendCommand.mockClear() + navigate('https://example.org/') + const observed = await observe(opened, 'https://example.org/') + expect(opened.handle.debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + expect(observed.requestIdentity).toEqual({ kind: 'process', userAgent: GUEST_CLEAN_UA }) }) it('heals a mid-redirect process override on the next navigation', async () => { diff --git a/src/main/browser/browser-manager-viewport-crashed-guest.test.ts b/src/main/browser/browser-manager-viewport-crashed-guest.test.ts new file mode 100644 index 00000000000..696be1a474b --- /dev/null +++ b/src/main/browser/browser-manager-viewport-crashed-guest.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn(), + processUserAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' +})) + +vi.mock('electron', () => ({ + app: { getPath: mocks.appGetPathMock }, + BrowserWindow: { fromWebContents: mocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: mocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: mocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: mocks.screenGetCursorScreenPointMock }, + webContents: { fromId: mocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: mocks.openPopupWithOriginBarMock +})) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ mode: 'clean', userAgent: mocks.processUserAgent }) +})) + +import { browserManager } from './browser-manager' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { createViewportGuestFactory } from './browser-manager-viewport-test-fixtures' + +const makeGuest = createViewportGuestFactory(mocks) +const PHONE = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } as const + +const metricsWrites = (sendCommand: ReturnType): unknown[][] => + sendCommand.mock.calls.filter(([method]) => method === 'Emulation.setDeviceMetricsOverride') + +describe('browserManager viewport on a guest whose renderer crashed', () => { + beforeEach(() => { + resetBrowserManagerMocks(mocks) + resetBrowserManagerState() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('never resizes the dead guest, and applies the preset once the page reloads', async () => { + const { guest, debuggerSendCommand, setRendererCrashed } = makeGuest(42501) + mocks.webContentsFromIdMock.mockReturnValue(guest) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the viewport fixture implements every WebContents member these paths touch. + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-crashed', + webContentsId: 42501, + rendererWebContentsId + }) + + // Chromium would segfault the main process on this write (its view is gone with the renderer). + setRendererCrashed(true) + await expect(browserManager.setViewportOverride('tab-crashed', PHONE)).resolves.toBe(false) + expect(metricsWrites(debuggerSendCommand)).toEqual([]) + + // The reloaded page's dom-ready reapplies the store's preset. + setRendererCrashed(false) + await expect(browserManager.setViewportOverride('tab-crashed', PHONE)).resolves.toBe(true) + expect(metricsWrites(debuggerSendCommand)).toEqual([ + ['Emulation.setDeviceMetricsOverride', PHONE] + ]) + }) +}) diff --git a/src/main/browser/browser-manager-viewport-ownership.test.ts b/src/main/browser/browser-manager-viewport-ownership.test.ts index db2d0b9a38f..82984751011 100644 --- a/src/main/browser/browser-manager-viewport-ownership.test.ts +++ b/src/main/browser/browser-manager-viewport-ownership.test.ts @@ -161,7 +161,8 @@ describe('browser viewport operation ownership', () => { expect(registeredGuests.get('replacement')).toBe(103) expect(presetIntents.get('replacement')).toEqual({ guestWebContentsId: 103, - override: desktop + requested: desktop, + applied: desktop }) }) @@ -176,7 +177,11 @@ describe('browser viewport operation ownership', () => { await browserManager.setViewportOverride('late-delete', mobile) gate.resolve() await expect(result).resolves.toBe(false) - expect(presetIntents.get('late-delete')).toEqual({ guestWebContentsId: 105, override: mobile }) + expect(presetIntents.get('late-delete')).toEqual({ + guestWebContentsId: 105, + requested: mobile, + applied: mobile + }) expect(registeredGuests.get('late-delete')).toBe(105) }) @@ -190,7 +195,11 @@ describe('browser viewport operation ownership', () => { await gate.entered gate.reject(new Error('Protocol error')) await expect(result).resolves.toBe(false) - expect(presetIntents.get('same-owner')).toEqual({ guestWebContentsId: 106, override: null }) + expect(presetIntents.get('same-owner')).toEqual({ + guestWebContentsId: 106, + requested: null, + applied: null + }) expect(handle.presentedUserAgent()).toContain('iPhone') expect(cdpUserAgentOverrides.has(106)).toBe(true) }) @@ -205,7 +214,11 @@ describe('browser viewport operation ownership', () => { await browserManager.setViewportOverride('late-apply', desktop) gate.resolve() await expect(pending).resolves.toBe(false) - expect(presetIntents.get('late-apply')).toEqual({ guestWebContentsId: 111, override: desktop }) + expect(presetIntents.get('late-apply')).toEqual({ + guestWebContentsId: 111, + requested: desktop, + applied: desktop + }) expect(old.debuggerSendCommand).not.toHaveBeenCalledWith( 'Emulation.setUserAgentOverride', expect.anything() @@ -253,7 +266,8 @@ describe('browser viewport operation ownership', () => { expect(pendingOperations.size).toBe(0) expect(presetIntents.get('queued-replacement')).toEqual({ guestWebContentsId: 115, - override: mobile + requested: mobile, + applied: mobile }) expect(replacement.presentedUserAgent()).toContain('iPhone') }) @@ -267,18 +281,25 @@ describe('browser viewport operation ownership', () => { const third = browserManager.setViewportOverride('serialized', null) gate.resolve() expect(await Promise.all([first, second, third])).toEqual([true, true, true]) - // Identity follows the last request, which was "no preset" before any identity write ran, so the - // mobile UA is never installed at all. + // Identity follows each viewport as it is applied, so the mobile UA stands only while the mobile + // metrics do and is cleared once the desktop metrics land. expect(handle.debuggerSendCommand.mock.calls.map(([method]) => method)).toEqual([ 'Emulation.setDeviceMetricsOverride', 'Emulation.setTouchEmulationEnabled', + 'Emulation.setUserAgentOverride', 'Emulation.setDeviceMetricsOverride', 'Emulation.setTouchEmulationEnabled', + 'Emulation.setUserAgentOverride', 'Emulation.clearDeviceMetricsOverride', 'Emulation.setTouchEmulationEnabled' ]) + expect(handle.presentedUserAgent()).toBe(GUEST_CLEAN_UA) expect(pendingOperations.size).toBe(0) - expect(presetIntents.get('serialized')).toEqual({ guestWebContentsId: 116, override: null }) + expect(presetIntents.get('serialized')).toEqual({ + guestWebContentsId: 116, + requested: null, + applied: null + }) }) it.each([false, true])( diff --git a/src/main/browser/browser-manager-viewport-scroll-state.ts b/src/main/browser/browser-manager-viewport-scroll-state.ts index 04063fb3c30..533dc78f34d 100644 --- a/src/main/browser/browser-manager-viewport-scroll-state.ts +++ b/src/main/browser/browser-manager-viewport-scroll-state.ts @@ -4,18 +4,22 @@ import type { BrowserViewportScrollState } from '../../shared/browser-workspace-types' +type ViewportPresetState = { + guestWebContentsId: number + requested: BrowserViewportOverride | null + /** The preset's device metrics Chromium accepted for this guest; null when none stands. */ + applied: BrowserViewportOverride | null +} + /** * Renderer routing plus the host-side viewport-preset geometry the wheel path needs to decide * whether a scroll belongs to the emulated viewport or to the guest page. */ export abstract class BrowserManagerViewportScrollState { protected readonly rendererWebContentsIdByTabId = new Map() - // Why: the requested preset, not the applied one — host-side wheel panning and the tab's identity - // both follow what was asked for; replacement guests must not inherit a retired guest's preset. - protected readonly viewportPresetByTabId = new Map< - string, - { guestWebContentsId: number; override: BrowserViewportOverride | null } - >() + // Why both: host-side wheel panning follows the requested preset, while the tab's identity follows + // the one Chromium actually applied. Replacement guests must not inherit a retired guest's preset. + protected readonly viewportPresetByTabId = new Map() protected readonly viewportScrollStateByTabId = new Map() setViewportScrollState( @@ -70,6 +74,17 @@ export abstract class BrowserManagerViewportScrollState { ) } + protected recordAppliedViewportOverride( + browserTabId: string, + guestWebContentsId: number, + applied: BrowserViewportOverride | null + ): void { + const preset = this.viewportPresetByTabId.get(browserTabId) + if (preset?.guestWebContentsId === guestWebContentsId) { + this.viewportPresetByTabId.set(browserTabId, { ...preset, applied }) + } + } + protected resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) if (!rendererWebContentsId) { diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 959e958491c..805988e7702 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -35,6 +35,8 @@ export type ViewportGuestHandle = { debuggerSendCommand: ReturnType debuggerIsAttached: ReturnType debuggerAttach: ReturnType + /** Flips what isCrashed() reports, as a renderer death and its reload do. */ + setRendererCrashed: (crashed: boolean) => void setGuestUserAgent: (ua: string) => void commitNavigationTo: (nextUrl: string) => void webContentsUserAgent: () => string @@ -55,6 +57,7 @@ export function createViewportGuestFactory( const debuggerIsAttached = vi.fn(() => true) const debuggerAttach = vi.fn() let currentUa = mocks.processUserAgent ?? GUEST_ELECTRON_UA + let rendererCrashed = false // Why: getURL() reports the last COMMITTED url — it does not move at did-start-navigation. let committedUrl = url // Chromium applies CDP commands in issue order, so a later-issued write wins however they settle, @@ -81,6 +84,7 @@ export function createViewportGuestFactory( const guest = { id, isDestroyed: vi.fn(() => false), + isCrashed: vi.fn(() => rendererCrashed), getType: vi.fn(() => 'webview'), getURL: vi.fn(() => committedUrl), getUserAgent: vi.fn(() => currentUa), @@ -107,6 +111,9 @@ export function createViewportGuestFactory( debuggerSendCommand, debuggerIsAttached, debuggerAttach, + setRendererCrashed: (crashed: boolean) => { + rendererCrashed = crashed + }, setGuestUserAgent: (ua: string) => { currentUa = ua }, diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts index addd6dc82ed..64e50e5ea48 100644 --- a/src/main/browser/browser-manager-viewport.ts +++ b/src/main/browser/browser-manager-viewport.ts @@ -6,6 +6,7 @@ import { } from '../../shared/browser-annotation-viewport-bridge' import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' +import { sendGuestCdpCommand } from './guest-cdp-command' // Why no maxTouchPoints: Chromium rejects values outside 1..16 even when disabling, which left // touch emulation (and no-hover media features) on after leaving a mobile preset (#22749). @@ -41,11 +42,13 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (expectedWebContentsId !== undefined) { - // Record the request before CDP runs: host panning and the tab's identity both follow it, so a - // navigation mid-apply already sees it. The guest id fence keeps it off a replacement guest. + // Record the request before CDP runs so host panning follows it at once. What is applied only + // moves once Chromium accepts it. The guest id fence keeps both off a replacement guest. + const previous = this.viewportPresetByTabId.get(browserTabId) this.viewportPresetByTabId.set(browserTabId, { guestWebContentsId: expectedWebContentsId, - override + requested: override, + applied: previous?.guestWebContentsId === expectedWebContentsId ? previous.applied : null }) } // The renderer resizes the host before CDP completes; discard the old geometry until it @@ -153,7 +156,7 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec 'device metrics', () => override - ? dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + ? sendGuestCdpCommand(guest, 'Emulation.setDeviceMetricsOverride', { width: override.width, height: override.height, deviceScaleFactor: override.deviceScaleFactor, @@ -161,6 +164,11 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec }) : dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) ) + // Why record before any further await: a debugger detach clears the metrics, and its handler + // must land after this write, not be overwritten by it. + if (metricsApplied) { + this.recordAppliedViewportOverride(browserTabId, webContentsId, override) + } const touchApplied = await this.runViewportEmulationStep(browserTabId, 'touch emulation', () => dbg.sendCommand( 'Emulation.setTouchEmulationEnabled', @@ -170,8 +178,8 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { return false } - // Why: identity is not an emulation step. It follows the requested preset whatever the steps - // above did, so a failed metrics or touch write can never strand a mobile identity on the tab. + // Why: identity follows the device metrics Chromium actually holds, not the request, so a failed + // metrics write never pairs a phone identity with a desktop viewport, or the reverse. const identityApplied = await this.runViewportEmulationStep(browserTabId, 'identity', () => this.retargetTabIdentity(guest, this.resolveTabNavigationUrl(guest)) ) diff --git a/src/main/browser/browser-route-webrtc-egress.electron.test.ts b/src/main/browser/browser-route-webrtc-egress.electron.test.ts index 23463f2f84a..f561ec500ee 100644 --- a/src/main/browser/browser-route-webrtc-egress.electron.test.ts +++ b/src/main/browser/browser-route-webrtc-egress.electron.test.ts @@ -110,7 +110,7 @@ async function probe() { (async () => { const peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:\${target}:\${udpAddress.port}' }], - iceCandidatePoolSize: 1 + iceCandidatePoolSize: 0 }) globalThis.__webrtcEgressPeer = peer peer.createDataChannel('probe') diff --git a/src/main/browser/browser-screencast-debugger-command.ts b/src/main/browser/browser-screencast-debugger-command.ts index 3a607e416ec..b32eef8d308 100644 --- a/src/main/browser/browser-screencast-debugger-command.ts +++ b/src/main/browser/browser-screencast-debugger-command.ts @@ -2,15 +2,22 @@ import type { WebContents } from 'electron' const DEBUGGER_COMMAND_TIMEOUT_MS = 8_000 -export async function sendDebuggerCommand( +export function sendDebuggerCommand( dbg: WebContents['debugger'], method: string, params: Record = {} +): Promise { + return runDebuggerCommandWithTimeout(method, () => dbg.sendCommand(method, params)) +} + +export async function runDebuggerCommandWithTimeout( + method: string, + send: () => Promise ): Promise { let timeout: ReturnType | null = null try { return await Promise.race([ - Promise.resolve().then(() => dbg.sendCommand(method, params)), + Promise.resolve().then(send), new Promise((_, reject) => { timeout = setTimeout(() => { reject(new Error(`Timed out while running ${method}.`)) diff --git a/src/main/browser/browser-screencast-device-metrics.ts b/src/main/browser/browser-screencast-device-metrics.ts index ff805d14604..96037bd53ae 100644 --- a/src/main/browser/browser-screencast-device-metrics.ts +++ b/src/main/browser/browser-screencast-device-metrics.ts @@ -1,5 +1,9 @@ import type { Debugger, WebContents } from 'electron' -import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import { + runDebuggerCommandWithTimeout, + sendDebuggerCommand +} from './browser-screencast-debugger-command' +import { sendGuestCdpCommand } from './guest-cdp-command' import type { BrowserScreencastOptions } from './browser-screencast-stream-types' import { positiveInteger, positiveNumber } from './browser-screencast-viewport-fit' @@ -15,6 +19,8 @@ export function createBrowserScreencastDeviceMetrics( options: BrowserScreencastOptions ): BrowserScreencastDeviceMetrics { let deviceMetricsOverridden = false + const sendViewportCommand = (method: string, params: Record): Promise => + runDebuggerCommandWithTimeout(method, () => sendGuestCdpCommand(webContents, method, params)) const clearDeviceMetricsOverride = async (): Promise => { if (webContents.isDestroyed() || !dbg.isAttached()) { @@ -38,13 +44,13 @@ export function createBrowserScreencastDeviceMetrics( // Why: Back/Forward and cross-process navigations can drop emulation while // the screencast remains attached. Reapply before fallback captures so the // page lays out at the client pane size, not the host BrowserView size. - await sendDebuggerCommand(dbg, 'Emulation.setDeviceMetricsOverride', { + await sendViewportCommand('Emulation.setDeviceMetricsOverride', { width: viewportWidth, height: viewportHeight, deviceScaleFactor, mobile: options.mobile === true }) - await sendDebuggerCommand(dbg, 'Emulation.setVisibleSize', { + await sendViewportCommand('Emulation.setVisibleSize', { width: viewportWidth, height: viewportHeight }).catch(() => {}) diff --git a/src/main/browser/browser-screencast-lifecycle.test.ts b/src/main/browser/browser-screencast-lifecycle.test.ts index 251b53fd469..fdf586b0ef8 100644 --- a/src/main/browser/browser-screencast-lifecycle.test.ts +++ b/src/main/browser/browser-screencast-lifecycle.test.ts @@ -21,7 +21,7 @@ function createWebContents() { attached = false }) debuggerApi.sendCommand = vi.fn(async () => ({})) - return { isDestroyed: vi.fn(() => false), debugger: debuggerApi } + return { isDestroyed: vi.fn(() => false), isCrashed: vi.fn(() => false), debugger: debuggerApi } } describe('browser screencast lifecycle', () => { diff --git a/src/main/browser/browser-screencast-snapshot-scaling.test.ts b/src/main/browser/browser-screencast-snapshot-scaling.test.ts index 4358d618376..1398214f2dc 100644 --- a/src/main/browser/browser-screencast-snapshot-scaling.test.ts +++ b/src/main/browser/browser-screencast-snapshot-scaling.test.ts @@ -20,7 +20,12 @@ function createMockWebContents(capturePage: () => Promise) { attached = false }) dbg.sendCommand = vi.fn(async () => ({})) - return { isDestroyed: vi.fn(() => false), debugger: dbg, capturePage: vi.fn(capturePage) } + return { + isDestroyed: vi.fn(() => false), + isCrashed: vi.fn(() => false), + debugger: dbg, + capturePage: vi.fn(capturePage) + } } function createCapturedImage(width: number, height: number) { diff --git a/src/main/browser/browser-screencast-web-contents-test-double.ts b/src/main/browser/browser-screencast-web-contents-test-double.ts index 13811850c68..030eec63783 100644 --- a/src/main/browser/browser-screencast-web-contents-test-double.ts +++ b/src/main/browser/browser-screencast-web-contents-test-double.ts @@ -10,6 +10,7 @@ export type MockScreencastDebugger = EventEmitter & { export type MockScreencastWebContents = { isDestroyed: ReturnType + isCrashed: ReturnType debugger: MockScreencastDebugger } @@ -29,6 +30,7 @@ export function createMockScreencastWebContents(): MockScreencastWebContents { return { isDestroyed: vi.fn(() => false), + isCrashed: vi.fn(() => false), debugger: dbg } } diff --git a/src/main/browser/browser-session-registry-import-boundary.test.ts b/src/main/browser/browser-session-registry-import-boundary.test.ts deleted file mode 100644 index 96950797c4a..00000000000 --- a/src/main/browser/browser-session-registry-import-boundary.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { expect, it } from 'vitest' - -it('keeps the cookie fixture registry outside the persistence barrel graph', () => { - const source = readFileSync(join(__dirname, 'browser-session-registry.ts'), 'utf8') - - expect(source).toMatch( - /import\s*\{\s*getCanonicalUserDataPath\s*\}\s*from\s*['"]\.\.\/persistence\/loading-store\/user-data-path['"]/ - ) - expect(source).not.toMatch(/from\s*['"]\.\.\/persistence['"]/) -}) diff --git a/src/main/browser/cdp-debugger-channel.ts b/src/main/browser/cdp-debugger-channel.ts index f1cddbe20f1..b0c790138e8 100644 --- a/src/main/browser/cdp-debugger-channel.ts +++ b/src/main/browser/cdp-debugger-channel.ts @@ -3,6 +3,7 @@ import type { WebContents } from 'electron' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' import type { CdpClientResponseWriter } from './cdp-client-response-writer' import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' +import { sendGuestCdpCommand } from './guest-cdp-command' /** * The IO boundary with webContents.debugger: lease-based attach, event fan-out to @@ -86,10 +87,9 @@ export class CdpDebuggerChannel { params: Record, sessionId?: string ): Promise { - const command = sessionId - ? this.webContents.debugger.sendCommand(method, params, sessionId) - : this.webContents.debugger.sendCommand(method, params) - return Promise.resolve(command) + return sessionId + ? sendGuestCdpCommand(this.webContents, method, params, sessionId) + : sendGuestCdpCommand(this.webContents, method, params) } forwardCommand( diff --git a/src/main/browser/cdp-debugger-lifecycle.ts b/src/main/browser/cdp-debugger-lifecycle.ts index 225eb689dba..0880f9926a4 100644 --- a/src/main/browser/cdp-debugger-lifecycle.ts +++ b/src/main/browser/cdp-debugger-lifecycle.ts @@ -4,6 +4,7 @@ import type { CdpTabState } from './cdp-auxiliary-commands' import type { CdpCommandSender } from './snapshot-engine' import type { CdpBridgeState } from './cdp-bridge-state' import { createCdpDebuggerMessageListener } from './cdp-debugger-events' +import { sendGuestCdpCommand } from './guest-cdp-command' export class CdpDebuggerLifecycle { constructor(private readonly bridgeState: CdpBridgeState) {} @@ -83,7 +84,7 @@ export class CdpDebuggerLifecycle { makeCdpSender(guest: WebContents, sessionId?: string): CdpCommandSender { return (method: string, params?: Record) => { - const command = guest.debugger.sendCommand(method, params, sessionId) as Promise + const command = sendGuestCdpCommand(guest, method, params, sessionId) // Why: Electron's CDP sendCommand can hang on a stale debugger session, so a 10s timeout bounds the RPC. let timer: ReturnType return Promise.race([ diff --git a/src/main/browser/cdp-ws-proxy-test-harness.ts b/src/main/browser/cdp-ws-proxy-test-harness.ts index c66a3cbf6b1..3f5765c18ab 100644 --- a/src/main/browser/cdp-ws-proxy-test-harness.ts +++ b/src/main/browser/cdp-ws-proxy-test-harness.ts @@ -25,6 +25,7 @@ export type MockWebContents = { webContents: { debugger: MockDebugger isDestroyed: () => boolean + isCrashed: () => boolean focus: Mock<() => void> printToPDF: Mock<() => Promise> reload: Mock<() => void> @@ -72,6 +73,7 @@ export function createMockWebContents(): MockWebContents { webContents: { debugger: debuggerObj, isDestroyed: () => destroyed, + isCrashed: () => false, focus: vi.fn(), printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), reload: vi.fn(), diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index f744998ab24..db8eb82a1f8 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -803,23 +803,4 @@ describe('CdpWsProxy', () => { expect(removedEvents).toEqual(expect.arrayContaining(['message', 'close'])) offSpy.mockRestore() }) - - it('rejects inflight requests on stop', async () => { - let resolveCommand: (v: unknown) => void - mock.webContents.debugger.sendCommand.mockImplementation( - () => - new Promise((r) => { - resolveCommand = r as (v: unknown) => void - }) - ) - - const client = await connect(endpoint) - client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} })) - - await new Promise((r) => setTimeout(r, 10)) - await proxy.stop() - - resolveCommand!({}) - client.close() - }) }) diff --git a/src/main/browser/guest-cdp-command.test.ts b/src/main/browser/guest-cdp-command.test.ts new file mode 100644 index 00000000000..3ad71959681 --- /dev/null +++ b/src/main/browser/guest-cdp-command.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { BrowserError } from './browser-error' +import { sendGuestCdpCommand } from './guest-cdp-command' + +function makeGuest(state: { crashed?: boolean; destroyed?: boolean } = {}) { + const sendCommand = vi.fn(async () => ({ ok: true })) + const guest = { + isDestroyed: vi.fn(() => state.destroyed ?? false), + isCrashed: vi.fn(() => { + if (state.destroyed) { + throw new Error('Object has been destroyed') + } + return state.crashed ?? false + }), + debugger: { sendCommand } + } + return { guest, sendCommand } +} + +describe('sendGuestCdpCommand', () => { + it.each(['Emulation.setDeviceMetricsOverride', 'Emulation.setVisibleSize'])( + 'refuses %s while the renderer is gone instead of letting Chromium crash the app', + async (method) => { + const { guest, sendCommand } = makeGuest({ crashed: true }) + const sent = sendGuestCdpCommand(guest, method, { width: 375, height: 667 }) + await expect(sent).rejects.toBeInstanceOf(BrowserError) + await expect(sent).rejects.toMatchObject({ code: 'browser_cdp_error' }) + expect(sendCommand).not.toHaveBeenCalled() + } + ) + + it('refuses a resize on a destroyed guest without asking it whether it crashed', async () => { + const { guest, sendCommand } = makeGuest({ destroyed: true }) + await expect( + sendGuestCdpCommand(guest, 'Emulation.setDeviceMetricsOverride', { width: 1, height: 1 }) + ).rejects.toBeInstanceOf(BrowserError) + expect(sendCommand).not.toHaveBeenCalled() + }) + + it('still sends commands that do not resize the view to a crashed guest', async () => { + const { guest, sendCommand } = makeGuest({ crashed: true }) + await expect( + sendGuestCdpCommand(guest, 'Emulation.setUserAgentOverride', { userAgent: 'x' }) + ).resolves.toEqual({ ok: true }) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { userAgent: 'x' }) + }) + + it('sends a resize to a live guest, and passes a session id only when one is given', async () => { + const { guest, sendCommand } = makeGuest() + await sendGuestCdpCommand(guest, 'Emulation.setVisibleSize', { width: 2, height: 3 }) + await sendGuestCdpCommand(guest, 'DOM.enable', {}, 'iframe-session') + expect(sendCommand.mock.calls).toEqual([ + ['Emulation.setVisibleSize', { width: 2, height: 3 }], + ['DOM.enable', {}, 'iframe-session'] + ]) + }) +}) diff --git a/src/main/browser/guest-cdp-command.ts b/src/main/browser/guest-cdp-command.ts new file mode 100644 index 00000000000..ed7735df2d8 --- /dev/null +++ b/src/main/browser/guest-cdp-command.ts @@ -0,0 +1,42 @@ +import type { WebContents } from 'electron' +import { BrowserError } from './browser-error' + +// Why: Chromium resizes the page's view for these without checking the view still exists +// (WebContentsImpl::SetDeviceEmulationSize). A crashed renderer takes its view with it until the +// reload builds a new one, so one of these sent in that gap segfaults Orca's main process. +const VIEW_RESIZING_CDP_METHODS: ReadonlySet = new Set([ + 'Emulation.setDeviceMetricsOverride', + 'Emulation.setVisibleSize' +]) + +type GuestCdpTarget = Pick & { + debugger: Pick +} + +/** + * The gate for guest CDP commands: every viewport writer and every sender that forwards a caller's + * method (agent bridge, CDP proxy) goes through here, so none can hand Chromium a command that + * crashes the app while the guest's renderer is dead. + */ +export function sendGuestCdpCommand( + guest: GuestCdpTarget, + method: string, + params?: Record, + sessionId?: string +): Promise { + // Why same task as the send: isCrashed() flips in the same Chromium task that drops the view, + // so checking right before sendCommand leaves no gap for the renderer to die in between. + if (VIEW_RESIZING_CDP_METHODS.has(method) && (guest.isDestroyed() || guest.isCrashed())) { + return Promise.reject( + new BrowserError( + 'browser_cdp_error', + 'The page crashed; its viewport can be changed again once it reloads.' + ) + ) + } + return Promise.resolve( + sessionId === undefined + ? guest.debugger.sendCommand(method, params) + : guest.debugger.sendCommand(method, params, sessionId) + ) +} diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts index 206ccbd82af..09ec7813ffe 100644 --- a/src/main/claude-accounts/claude-account-service-api-parity.test.ts +++ b/src/main/claude-accounts/claude-account-service-api-parity.test.ts @@ -1,28 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' import { ClaudeAccountService } from './service' -import type { ClaudeAccountAddTarget, ClaudeAccountImportOptions } from './service' +import type { ClaudeAccountAddTarget } from './service' vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) -type PublicClaudeAccountService = { - listAccounts(): ClaudeRateLimitAccountsState - addAccount(target?: ClaudeAccountAddTarget): Promise - addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions - ): Promise - reauthenticateAccount(accountId: string): Promise - removeAccount(accountId: string): Promise - selectAccount(accountId: string | null): Promise - selectAccountForTarget( - accountId: string | null, - target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } - ): Promise - cancelPendingLogin(): boolean - getRuntimeConfigDir(target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): string -} - function createService(): ClaudeAccountService { const settings = { claudeManagedAccounts: [], @@ -41,16 +23,7 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise: new Promise((done) => (resolve = done)), resolve } } -describe('ClaudeAccountService API parity', () => { - it('keeps the exact runtime export and assignable public surface', async () => { - const runtimeExports = await import('./service') - const service: PublicClaudeAccountService = createService() - - expect(Object.keys(runtimeExports)).toEqual(['ClaudeAccountService']) - expect(service.cancelPendingLogin()).toBe(false) - expect(service.getRuntimeConfigDir()).toBe('/tmp/claude') - }) - +describe('ClaudeAccountService mutation serialization', () => { it('serializes mutations within one service', async () => { const service = createService() const first = deferred() diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl new file mode 100644 index 00000000000..38dd3136218 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl @@ -0,0 +1,12 @@ +{"at": 1, "kind": "meta", "scenario": "auth-failed", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 288, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "39446645-6ec0-4807-9e18-95ac04c13143", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "b8e8b60c-1592-4f05-a21a-853f6a1a5040", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 302, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "queued", "uuid": "17b4b010-4615-4bf0-9b63-7936336cd7c4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 306, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "started", "uuid": "b1ed2141-c5a2-4c7e-8403-dbeb53d15be2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 321, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ad41aad3-ab9d-4b3c-a9f2-8610044d9482"}} +{"at": 342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "timestamp": "2026-09-29T10:33:13.793Z", "isReplay": true}} +{"at": 342, "kind": "frame", "frame": {"type": "assistant", "message": {"diagnostics": null, "id": "58f67b61-a560-4d83-b099-4f1b2e920de8", "container": null, "model": "", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null}, "content": [{"type": "text", "text": "Not logged in \u00b7 Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c5de4d1-e149-4201-b18b-b0021d4650f2", "timestamp": "2026-09-29T10:33:13.809Z", "error": "authentication_failed", "is_api_error_message": true, "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"]}} +{"at": 346, "kind": "frame", "frame": {"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "Not logged in \u00b7 Please run /login", "type": "result", "duration_ms": 36, "uuid": "9736f712-8292-41be-bd62-305633c7e152", "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"], "queued_turn_count": 0, "result_index": 0}} +{"at": 347, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "cancelled", "uuid": "64028711-21fb-4a47-99c9-8ae448e4c577", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 347, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "081649ae-6232-43d6-b31b-6d1da6167020", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl new file mode 100644 index 00000000000..50cab7abe66 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl @@ -0,0 +1,28 @@ +{"at": 15, "kind": "meta", "scenario": "batch-lead", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 334, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "9d3d1c58-6d69-408a-ae73-73d5c9656e9f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 376, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "30ed6f57-a819-4c93-b4d3-7d76838c1950", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 410, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 413, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "2dfef2b1-c08e-4a66-94b9-c3bbbe8d6adb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 413, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "queued", "uuid": "378fac6f-7ffb-417f-b9d3-4311c3d99d64", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 414, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "started", "uuid": "4bdc9a15-e613-42f3-a912-f6bcf8712999", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 474, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "b3869f1d-27b1-488a-a8a1-bde1a52fbf84", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 477, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "1e0b56e7-60ab-430d-910f-06c0337fb209"}} +{"at": 1478, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "timestamp": "2026-09-29T10:36:14.355Z", "isReplay": true}} +{"at": 1478, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "queued", "uuid": "9438512a-5380-48da-8ddf-6adffc28cfbb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "dispatch", "clientMessageId": "client-C", "sentUuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "queued", "uuid": "4f1b992a-26e8-43c5-af40-c3e5ef5707c8", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8"}} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "cancelled", "uuid": "f52f03d1-df02-432c-8bb6-c94062877e6f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10848, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "89d623dc-6a78-46fc-9123-ee06e2d61a6b", "timestamp": "2026-09-29T10:36:24.740Z", "request_id": "req_scrubbed01", "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"]}} +{"at": 10885, "kind": "frame", "frame": {"duration_api_ms": 11864, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10419, "type": "result", "duration_ms": 10453, "uuid": "922da184-ba96-4a9e-a133-27dd7f45818f", "ttft_stream_ms": 1063, "time_to_request_ms": 71, "first_content_frame_ms": 1063, "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"], "request_sent_wall_ms": 1790678174393, "queued_turn_count": 0, "result_index": 0}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "completed", "uuid": "3df6d400-c72e-4521-92f4-cc286ab007b7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "started", "uuid": "e7087639-8080-4803-b8e4-638cc31888e5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "6fce32e9-e653-477f-b6b6-4d10267b2991", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e546544b-76be-4d78-9094-9a8329a6a756"}} +{"at": 12309, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "timestamp": "2026-09-29T10:36:24.795Z", "isReplay": true}} +{"at": 12310, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed03", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "C-DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "63316a0d-face-40ab-b7c5-a2f3c7aef73b", "timestamp": "2026-09-29T10:36:26.216Z", "request_id": "req_scrubbed02", "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"]}} +{"at": 12331, "kind": "frame", "frame": {"duration_api_ms": 13267, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.11527720000000001, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "output_tokens": 7, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 9027, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 4, "output_tokens": 7, "cache_read_input_tokens": 15502, "cache_creation_input_tokens": 9027, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1215, "outputTokens": 1525, "cacheReadInputTokens": 38506, "cacheCreationInputTokens": 9027, "webSearchRequests": 0, "costUSD": 0.11527720000000001, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "C-DONE", "ttft_ms": 1424, "type": "result", "duration_ms": 1438, "uuid": "fb897d43-901b-4f47-a701-d4d5f4cf8562", "ttft_stream_ms": 1423, "time_to_request_ms": 23, "first_content_frame_ms": 1423, "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"], "request_sent_wall_ms": 1790678184816, "queued_turn_count": 0, "result_index": 1}} +{"at": 12331, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "completed", "uuid": "bccf83ae-642e-42f8-8a33-1448870fb137", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 12332, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5277a376-53d0-4a5d-b6f6-7669baccb91f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl new file mode 100644 index 00000000000..69284b8119d --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl @@ -0,0 +1,19 @@ +{"at": 0, "kind": "meta", "scenario": "cancel-async", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 207, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "ce5037b8-9e84-4b39-a598-cc4cea7123b4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 225, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "93e4cdab-4976-4646-a5ce-9544936bfe6e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 256, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "3746879b-3bb8-47f8-a371-9544bc5812a5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "queued", "uuid": "a75f748a-f917-4f8a-afea-28f022859753", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "started", "uuid": "e204ce9a-ee8a-4b64-ba12-eff1d33c52d1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 301, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "dad169fc-bced-4e8f-a40f-f3ea0537c763", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f33c5acc-1608-453c-86a0-2568002a67d0"}} +{"at": 1096, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "timestamp": "2026-09-29T10:35:47.486Z", "isReplay": true}} +{"at": 1097, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1097, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "queued", "uuid": "a021a2ec-59b0-4470-ab40-5c3af8b9dcb0", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1097, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded"}} +{"at": 1098, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "cancelled", "uuid": "75187135-edfc-4738-8890-3c5204d319f1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1098, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10491, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "16c7fded-8293-4480-a708-bae2361dc27c", "timestamp": "2026-09-29T10:35:57.675Z", "request_id": "req_scrubbed01", "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"]}} +{"at": 10532, "kind": "frame", "frame": {"duration_api_ms": 12016, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10214, "type": "result", "duration_ms": 10247, "uuid": "7aa2d6f4-d1dd-4749-ae09-7b0235246b51", "ttft_stream_ms": 834, "time_to_request_ms": 47, "first_content_frame_ms": 834, "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"], "request_sent_wall_ms": 1790678147508, "queued_turn_count": 0, "result_index": 0}} +{"at": 10532, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "completed", "uuid": "28426f29-739b-48dc-94e8-63e85e197192", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10533, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "b8e0b928-4ded-4ba4-8a88-5a0c5517657a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl new file mode 100644 index 00000000000..35230188f88 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl @@ -0,0 +1,20 @@ +{"at": 3, "kind": "meta", "scenario": "interrupt-lost", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 333, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "a8c39655-a598-42a7-943e-899b511643d9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 362, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "1bc17c32-c0d8-4c1d-ace0-03327bbcf13b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 416, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 421, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "ef60bf56-2954-4f1a-bfaf-502f4cf7e5f9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 421, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "queued", "uuid": "8f4de258-0da7-4ff7-a972-24c5d2b9e593", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 422, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "started", "uuid": "e70550c4-18ff-4795-9ddf-9dd267e82b9e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 490, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "38344e0c-d00c-40dd-9da2-6cd73d7ad800"}} +{"at": 2520, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "timestamp": "2026-09-29T10:35:25.741Z", "isReplay": true}} +{"at": 3136, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d15945e1-6168-458e-9b2d-49f427327af2", "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda"}} +{"at": 3137, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 3137, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "queued", "uuid": "f6865348-9b42-410c-96e8-5bf01e1d68de", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3137, "kind": "control", "request": {"subtype": "interrupt", "cancel_queued": true}} +{"at": 3138, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "cancelled", "uuid": "d4cf2dfc-2530-4fa6-9f47-efab0874177a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3138, "kind": "control-answer", "response": {"still_queued": [], "cancelled": ["0ca6fc96-94c3-4462-9524-a8341729ab91"]}} +{"at": 3139, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user]"}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ce4fe796-391f-4acc-b37c-49b668682592", "timestamp": "2026-09-29T10:35:28.422Z"}} +{"at": 3149, "kind": "frame", "frame": {"duration_api_ms": 2176, "stop_reason": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.005256, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1209, "outputTokens": 21, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.005256, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "aborted_streaming", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 2, "subtype": "error_during_execution", "errors": ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"], "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "type": "result", "duration_ms": 2717, "uuid": "9551c994-3a32-4f9a-b205-be27ae648b93", "user_message_uuids": ["d0649b29-11d5-45e7-9034-6fb789e78eda"], "queued_turn_count": 0, "result_index": 0}} +{"at": 3150, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "cancelled", "uuid": "24526d3c-00cc-488a-b899-239756190772", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3150, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5de7c47b-2d4f-4f66-b301-d5e4de20e787", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts index f1a38466d41..421e4a9c719 100644 --- a/src/main/claude/claude-agent-sdk-import-boundary.test.ts +++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { spawnProcess } from '../../shared/child-process/run-process' @@ -9,115 +8,14 @@ import { spawnProcess } from '../../shared/child-process/run-process' * A user who never leaves the terminal/TUI Claude path must not pay for the SDK: * importing it evaluates a package that rewrites * `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves - * executables for every later subprocess, and a missing or incompatible install - * would take normal runtime startup down with it. The ordinary - * `OrcaRuntimeService` graph reaches the Claude transport module, so only a - * deferred import keeps that boundary — and only a walk of the real import graph - * keeps the next static import from quietly restoring it. + * executables for every later subprocess. Loading the structured runtime must not + * trip that rewrite, which is only true while the SDK stays behind a deferred + * import inside the session path. */ const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const REPO_ROOT = resolve(__dirname, '..', '..', '..') -/** The Electron main entry: everything the app loads before any session exists. */ -const ROOT = 'src/main/index.ts' -/** Proof the walk goes all the way into the Claude transport rather than stopping short. */ -const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts' - -/** - * Static, value-carrying specifiers only, read statement by statement so a - * multi-line `import { ... } from '...'` counts. `import type` is erased before - * the module ever loads and a bare `import(...)` is the deferral this guards, so - * neither is an edge the runtime traverses at load time. - */ -const STATEMENT_START = /^\s*(?:import|export)\b/ -const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/ -const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/ -const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/ -/** An import statement never spans more lines than its longest specifier list. */ -const MAX_STATEMENT_LINES = 60 - -function readSpecifiers(source: string): string[] { - const lines = source.split('\n') - const found: string[] = [] - for (let index = 0; index < lines.length; index += 1) { - const first = lines[index] as string - if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) { - continue - } - const sideEffect = SIDE_EFFECT_IMPORT.exec(first) - if (sideEffect) { - found.push(sideEffect[1] as string) - continue - } - for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) { - if (scan > index && STATEMENT_START.test(lines[scan] as string)) { - break - } - const specifier = FROM_SPECIFIER.exec(lines[scan] as string) - if (specifier) { - found.push(specifier[1] as string) - break - } - } - } - return found -} - -/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */ -function resolveRelative(fromFile: string, specifier: string): string | null { - const base = join(dirname(fromFile), specifier) - for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) { - if (existsSync(candidate) && statSync(candidate).isFile()) { - return candidate - } - } - return null -} - -function walkStaticImports(rootFile: string): { visited: Set; sdkImporters: string[] } { - const visited = new Set() - const sdkImporters: string[] = [] - const queue = [resolve(REPO_ROOT, rootFile)] - while (queue.length > 0) { - const file = queue.pop() as string - const key = relative(REPO_ROOT, file).split('\\').join('/') - if (visited.has(key)) { - continue - } - visited.add(key) - for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) { - if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) { - sdkImporters.push(key) - continue - } - if (!specifier.startsWith('.')) { - continue - } - const target = resolveRelative(file, specifier) - if (target) { - queue.push(target) - } - } - } - return { visited, sdkImporters } -} - describe('claude agent SDK import boundary', () => { - const walk = walkStaticImports(ROOT) - - it('walks a graph deep enough to reach the Claude transport', () => { - // Without this the guard passes for the wrong reason the moment the walk breaks. - expect(walk.visited.size).toBeGreaterThan(500) - expect([...walk.visited]).toContain(TRANSPORT_MODULE) - }) - - it('never reaches the SDK through a static import from the main entry', () => { - expect( - walk.sdkImporters, - `${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.` - ).toEqual([]) - }) - it('leaves the Windows executable-search environment alone when the runtime loads', async () => { // A vitest file runs in its own fork, so this is a clean process; the ambient // value is cleared first because the developer's own shell may carry one. diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index fc5d234ec87..7f351193029 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -17,6 +17,7 @@ import { resetHostTestOperationIds } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -52,10 +53,7 @@ beforeEach(async () => { sink = null clock = NOW closeSession = vi.fn(async () => true) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null @@ -81,7 +79,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index becadff90d3..a1c8da60cb7 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -11,6 +11,7 @@ import { openAgentSessionJournal } from '../native-chat/agent-session-journal/jo import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -49,7 +50,7 @@ afterEach(async () => { async function statusRowsFor(frames: Record[]) { const journal = await openAgentSessionJournal({ identity: IDENTITY, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts index ecd23480605..736583d0324 100644 --- a/src/main/claude/claude-background-task-row-journal.test.ts +++ b/src/main/claude/claude-background-task-row-journal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionEventSink, @@ -94,12 +95,16 @@ describe('Claude background task row journal', () => { } const resolver = new ClaudeBackgroundTaskIdentityResolver() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: false, reason: 'backpressure' }) expect(task.lastSerialized).toBeNull() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: true }) expect(task.lastSerialized).not.toBeNull() @@ -130,7 +135,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: true }) expect(calls).toEqual([ { @@ -153,7 +164,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -175,7 +187,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe({ @@ -203,7 +216,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) expect(rows.observe(START_BASH)).toBe(true) @@ -227,7 +241,8 @@ describe('Claude background task row journal', () => { tryAppendResolvedItemAndPublish: appendAndPublish }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -249,7 +264,8 @@ describe('Claude background task row journal', () => { })) }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) for (let index = 0; index < 512; index += 1) { @@ -287,7 +303,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: false, reason }) expect(task.lastSerialized).toBeNull() }) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index a4779898204..956bc770f74 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,6 +1,7 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' import { @@ -141,6 +142,8 @@ export function writeClaudeBackgroundTaskRow( identities: ClaudeBackgroundTaskIdentityResolver, id: string, row: ClaudeBackgroundTaskRow, + /** The turn the row belongs to, read once `beforeAppend` has opened it. */ + turnScope: () => AgentJournalTurnScope, /** Runs before admission to preserve turn-before-row ordering; duplicate * delivery skips it, and a retry reuses the turn the first attempt opened. */ beforeAppend?: () => void, @@ -157,7 +160,11 @@ export function writeClaudeBackgroundTaskRow( // recreated. Keep unresolved writes from distinct provider runs queued side // by using the provider's parent tool identity as the coalescing discriminator. const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null]) - const appendOptions = { coalescingKey, ...(lifecycle ? { lifecycle: true } : {}) } + const appendOptions = { + coalescingKey, + turnScope: turnScope(), + ...(lifecycle ? { lifecycle: true } : {}) + } const publishOptions = lifecycle ? { lifecycle: true } : {} const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) => identities.resolve(journal, id, row.toolUseId) diff --git a/src/main/claude/claude-background-task-row-test-support.ts b/src/main/claude/claude-background-task-row-test-support.ts index 450f4ebe292..af03378331b 100644 --- a/src/main/claude/claude-background-task-row-test-support.ts +++ b/src/main/claude/claude-background-task-row-test-support.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' // Shared fixtures and the sink harness the background-task row suites drive. // // Moved out of claude-background-task-rows.test.ts verbatim when that suite @@ -85,7 +86,8 @@ export function harness( sink, isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId), openOutputTurn: () => turnOpens.push(1), - now: () => (clock += 10) + now: () => (clock += 10), + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const keys = (): string[] => items.map((item) => diff --git a/src/main/claude/claude-background-task-row-writer.ts b/src/main/claude/claude-background-task-row-writer.ts index bb973a1ae70..0415503b9c2 100644 --- a/src/main/claude/claude-background-task-row-writer.ts +++ b/src/main/claude/claude-background-task-row-writer.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -19,6 +20,7 @@ export class ClaudeBackgroundTaskRowWriter { constructor( private readonly sink: StructuredAgentSessionEventSink, + private readonly turnScope: () => AgentJournalTurnScope, private readonly onPersistenceFailure?: (error: Error) => void ) {} @@ -33,6 +35,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, id, row, + this.turnScope, beforeAppend, lifecycle ) @@ -61,6 +64,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, pending.id, pending.row, + this.turnScope, undefined, pending.lifecycle ) diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index 64829d0493a..223760c55af 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -1,6 +1,7 @@ // One durable row per Claude background `task_id`, revised in place from the // lifecycle frames so a failed command prints once with the provider sentence. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { record, taskAliasId } from './claude-background-task-frames' @@ -42,10 +43,16 @@ export type ClaudeBackgroundTaskRowsDeps = { * reached the transcript is a nested child, and a top-level row minted for it * would claim an invocation the user never saw. */ isForwardedParentTool: (toolUseId: string) => boolean + /** Whether an earlier provider run of this session rostered this task as a + * subagent. Its announcement is not repeated, so this is the only route from + * a later frame for it — Claude's restart notice above all — to the roster. */ + rosteredByEarlierRun?: (taskId: string) => boolean /** Opens a turn for the frame being journaled. A typed row is provider * output, so writing one must reopen a turn the provider resumed itself — * otherwise the session renders the row while reporting idle. */ openOutputTurn?: (frame: Record, observedAt: number) => void + /** The turn a row written now belongs to: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope onPersistenceFailure?: (error: Error) => void now?: () => number } @@ -60,7 +67,11 @@ export class ClaudeBackgroundTaskRows { constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) { this.now = deps.now ?? (() => Date.now()) - this.writer = new ClaudeBackgroundTaskRowWriter(deps.sink, deps.onPersistenceFailure) + this.writer = new ClaudeBackgroundTaskRowWriter( + deps.sink, + deps.turnScope, + deps.onPersistenceFailure + ) this.overflowTerminalRows = new ClaudeOverflowTerminalRows( this.ledgers, this.now, @@ -130,6 +141,10 @@ export class ClaudeBackgroundTaskRows { if (this.ledgers.foreign.has(id)) { return true } + if (!this.rows.has(id) && this.deps.rosteredByEarlierRun?.(id) === true) { + this.ledgers.rememberForeign(id, 'roster') + return true + } if (message.subtype === 'task_notification') { return this.observeNotification(id, message) } diff --git a/src/main/claude/claude-captured-compact-frames.test-fixture.ts b/src/main/claude/claude-captured-compact-frames.test-fixture.ts new file mode 100644 index 00000000000..059ed560d6c --- /dev/null +++ b/src/main/claude/claude-captured-compact-frames.test-fixture.ts @@ -0,0 +1,219 @@ +// Claude Code 2.1.283 (SDK 0.3.251) stream-json captures of `/compact`, driven through `query()` with +// streaming input and cut to the protocol fields Orca reads. The session id and the summary text +// are replaced, and every frame carried `session_id: CAPTURED_COMPACT_SESSION_ID`; frame order, +// uuids and the relative clock (`at`, ms) are the captured ones. +// +// Verified from these frames: a finished and a stopped `/compact` both end in a `success` result +// with no `terminal_reason`, naming the command's input in `user_message_uuid`. Only the finished +// one carries a `compact_boundary`; the stopped one answers with a synthetic "Compaction canceled." +// instead. `command_lifecycle` frames bracket every input. + +export type CapturedCompactEvent = + | { at: number; sent: { text: string; uuid: string } } + | { at: number; interrupt: true } + | { at: number; frame: Record } + +export const CAPTURED_COMPACT_SESSION_ID = '00000000-0000-4000-8000-000000000001' + +const lifecycle = (commandUuid: string, state: string) => ({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state +}) + +/** A `/compact` left to finish, from its send to its result. */ +export const CAPTURED_COMPACT_SUCCEEDS: CapturedCompactEvent[] = [ + { at: 1750, sent: { text: '/compact', uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de' } }, + { at: 1751, frame: lifecycle('968b34fc-76cc-41bc-adfc-34f8840637a0', 'completed') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'queued') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'started') }, + { + at: 1757, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'de45cbf4-0f29-4ad7-a2a4-f9d9d8847f88' + } + }, + { + at: 6602, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'success', + uuid: 'c9cd62b1-02d9-48fc-bb07-1ccb4889c0af' + } + }, + { + at: 6608, + frame: { + type: 'system', + subtype: 'init', + uuid: '9cebf016-94d0-43d5-a23b-acbd606efeae', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 6609, + frame: { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { + trigger: 'manual', + pre_tokens: 13576, + post_tokens: 1011, + cumulative_dropped_tokens: 12565, + duration_ms: 4711 + }, + uuid: '1ed30bd1-8113-4c3d-b4ef-8020a2f80bfe' + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: false, + isSynthetic: true, + uuid: '4fa68de9-8e28-40d1-a332-da8dba6edd14', + message: { + role: 'user', + content: + 'This session is being continued from a previous conversation that ran out of context. [summary scrubbed]' + } + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: true, + uuid: '302b225c-05fd-45a5-ac2d-0435f9b50845', + message: { role: 'user', content: 'Compacted' } + } + }, + { + at: 6609, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de', + uuid: 'd4c86a55-621b-40ae-9f26-612c842d3ca2' + } + } +] + +/** A `/compact` interrupted 1.5 s in, then the next send and its own answer. */ +export const CAPTURED_COMPACT_STOPPED_THEN_SEND: CapturedCompactEvent[] = [ + { at: 9663, sent: { text: '/compact', uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d' } }, + { at: 9664, frame: lifecycle('0be27e6d-49b7-4ee8-a784-744b3fb3b55c', 'completed') }, + { at: 9664, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'queued') }, + { at: 9665, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'started') }, + { + at: 9666, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'c85f1131-b4a5-424e-af6a-85e8bd98b2fc' + } + }, + { at: 11166, interrupt: true }, + { + at: 11168, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'failed', + compact_error: 'API Error: Request was aborted.', + uuid: '7fede6b2-afdf-4455-8c69-096ae3fa6f55' + } + }, + { + at: 11170, + frame: { + type: 'system', + subtype: 'init', + uuid: '067d1daa-f0da-4ca0-a9de-bedaa72c606a', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 11170, + frame: { + type: 'assistant', + parent_tool_use_id: null, + uuid: '0d252793-d95f-4c80-b4a1-7309614b7eeb', + message: { + id: '9c14e2f0-6480-4760-92f8-aa73e461bc47', + model: '', + role: 'assistant', + content: [{ type: 'text', text: 'Compaction canceled.' }] + } + } + }, + { + at: 11170, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', + uuid: '523a8c1d-5054-422e-b81e-f5f350d992fd' + } + }, + { + at: 11170, + sent: { + text: 'Reply with the single word AFTERSTOP.', + uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e' + } + }, + { at: 11170, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'cancelled') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'queued') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'started') }, + { + at: 11200, + frame: { + type: 'system', + subtype: 'init', + uuid: '1eedc4e5-d48c-4682-b593-144466641d13', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 12778, + frame: { + type: 'assistant', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + parent_tool_use_id: null, + uuid: '39969651-5abd-4dba-ae80-ab1299c07a47', + message: { + id: 'msg_011CfUXeXFfvAtj1PLLjB1H4', + model: 'claude-opus-5-5', + role: 'assistant', + content: [{ type: 'text', text: 'AFTERSTOP' }] + } + } + }, + { + at: 12844, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + terminal_reason: 'completed', + result: 'AFTERSTOP', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + uuid: 'd9b4faaa-f9be-4d7b-9f81-a5106bd003f3' + } + }, + { at: 12844, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'completed') } +] diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts new file mode 100644 index 00000000000..c5570e476fd --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -0,0 +1,424 @@ +// Replays of real Claude CLI 2.1.280 sessions (`__fixtures__/claude-lifecycle-capture-*.jsonl`) +// in which the CLI withdrew a queued send, interrupted a turn, or failed one. The recorded +// frames are the script; at each recorded control request the test decides how Orca's side of +// it went — the answer arriving, lost, or failing — and the CLI's own frames from while that +// request was outstanding are delivered either way. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + type FakeConnection +} from './claude-structured-session-test-support' + +type CapturedEvent = + | { kind: 'meta'; providerSessionId: string } + | { kind: 'frame'; frame: Record } + | { kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } + | { kind: 'control'; request: Record } + | { kind: 'control-answer'; response: Record } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeCapturedEvent(value: unknown): CapturedEvent { + if (!isRecord(value)) { + throw new Error('capture line is not a recorded event') + } + if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { + return { kind: 'meta', providerSessionId: value.providerSessionId } + } + if (value.kind === 'frame' && isRecord(value.frame)) { + return { kind: 'frame', frame: value.frame } + } + if ( + value.kind === 'dispatch' && + typeof value.clientMessageId === 'string' && + typeof value.sentUuid === 'string' && + typeof value.text === 'string' + ) { + return { + kind: 'dispatch', + clientMessageId: value.clientMessageId, + sentUuid: value.sentUuid, + text: value.text + } + } + if (value.kind === 'control' && isRecord(value.request)) { + return { kind: 'control', request: value.request } + } + if (value.kind === 'control-answer' && isRecord(value.response)) { + return { kind: 'control-answer', response: value.response } + } + throw new Error(`capture line has unknown kind: ${String(value.kind)}`) +} + +function loadCapture(name: string): CapturedEvent[] { + const path = join(__dirname, '__fixtures__', `claude-lifecycle-capture-${name}.jsonl`) + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => decodeCapturedEvent(JSON.parse(line))) +} + +type Settlement = { sessionId: string } & ClaudeLateDispatchOutcome + +type ControlPoint = { + request: Record + /** The CLI's answer as recorded, mapped to this replay's uuids. */ + answer: Record + /** Delivers the frames the CLI emitted while the request was outstanding. */ + deliverInFlight: () => void + adapter: ClaudeStructuredSessionAdapter + connection: FakeConnection + routes: ReturnType['routes'] + liveUuid: (clientMessageId: string) => string +} + +async function replayCapture( + name: string, + options: { + /** What Orca did at the recorded control request; by default nothing (the CLI acted alone). */ + atControl?: (point: ControlPoint) => Promise + /** Drop these from the capture's init frames, as an older CLI would not advertise them. */ + withoutCapabilities?: string[] + /** Leave out captured frames, to model a sequence the capture brackets. */ + omitFrame?: (frame: Record) => boolean + /** Frames to deliver right after a captured one, in the capture's own uuids. */ + afterFrame?: (frame: Record) => Record[] + /** Stop after the control request settles; its tail answers the CLI's own control path. */ + stopAfterControl?: boolean + } = {} +) { + const capture = loadCapture(name) + const settlements: Settlement[] = [] + const idles: string[] = [] + const turnStates = new Map() + // The capture supplies every frame, startup proof included. + const claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1, + now: () => 1_700_000_200_000, + persistHandle: async () => {}, + onDispatchSettledLate: (settlement) => settlements.push(settlement), + onSessionIdle: ({ sessionId }) => idles.push(sessionId) + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turnStates.set(turn.turnId, turn.state) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + }) + const connection = claude.connections[0]! + + // Captured uuids -> the uuids the live dispatches mint during this replay. + const uuidMap = new Map() + const capturedSessionId = capture.flatMap((event) => + event.kind === 'meta' ? [event.providerSessionId] : [] + )[0]! + const mapUuids = (value: Record): Record => { + let text = JSON.stringify(value).replaceAll(capturedSessionId, PROVIDER_SESSION_ID) + for (const [captured, live] of uuidMap) { + text = text.replaceAll(captured, live) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + const deliver = (frame: Record): void => { + if (options.omitFrame?.(frame)) { + return + } + const mapped = mapUuids(frame) + if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { + mapped.capabilities = mapped.capabilities.filter( + (capability) => !options.withoutCapabilities!.includes(String(capability)) + ) + } + connection.handlers.onMessage?.(mapped) + for (const extra of options.afterFrame?.(frame) ?? []) { + connection.handlers.onMessage?.(mapUuids(extra)) + } + } + const liveUuid = (clientMessageId: string): string => { + const dispatch = capture.find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? (uuidMap.get(dispatch.sentUuid) ?? '') : '' + } + + let proofDelivered = false + for (let index = 0; index < capture.length; index++) { + const event = capture[index]! + if (event.kind === 'frame') { + deliver(event.frame) + proofDelivered = true + } else if (event.kind === 'dispatch') { + if (proofDelivered) { + await adapter.awaitStarted('session-1') + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: event.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid)) + } else if (event.kind === 'control') { + const answerAt = capture.findIndex( + (candidate, at) => at > index && candidate.kind === 'control-answer' + ) + if (answerAt === -1) { + throw new Error('capture recorded no answer for its control request') + } + const inFlight = capture.slice(index + 1, answerAt) + const answer = capture[answerAt] + let delivered = false + const deliverInFlight = (): void => { + if (!delivered) { + delivered = true + for (const frameEvent of inFlight) { + if (frameEvent.kind === 'frame') { + deliver(frameEvent.frame) + } + } + } + } + const atControl = options.atControl ?? (async (point) => point.deliverInFlight()) + await atControl({ + request: mapUuids(event.request), + answer: answer?.kind === 'control-answer' ? mapUuids(answer.response) : {}, + deliverInFlight, + adapter, + connection, + routes: claude.routes, + liveUuid + }) + deliverInFlight() + if (options.stopAfterControl) { + break + } + index = answerAt + } + } + const settlementsFor = (clientMessageId: string) => + settlements + .filter((settlement) => settlement.clientMessageId === clientMessageId) + .map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome) + return { settlementsFor, idles, turnStates, liveUuid, connection, adapter } +} + +const WITHDRAWN = { + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } +} + +function acceptedAs(uuid: string) { + return { providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid } } +} + +/** Orca's Stop with no turn named, as the chat sends it; the interrupt answers per `answer`. */ +function stopWithInterruptAnswer(answer: 'recorded' | 'lost') { + return async (point: ControlPoint): Promise => { + point.routes.interrupt = () => { + point.deliverInFlight() + if (answer === 'lost') { + throw new ClaudeControlRequestError('interrupt', 'Control request timed out') + } + return point.answer + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } +} + +describe('a Stop whose interrupt cancelled a queued follow-up (cancel_queued)', () => { + it.each(['recorded', 'lost'] as const)( + 'withdraws the follow-up once when the interrupt answer is %s, and leaves the stopped turn’s message accepted', + async (answer) => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer(answer) + }) + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + // The interrupted turn's own message was echoed first; its later `cancelled` changes nothing. + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) + + it('ignores a cancelled frame for a send it no longer holds, or never sent', async () => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer('lost') + }) + for (const commandUuid of [ + replay.liveUuid('client-A'), + replay.liveUuid('client-B'), + '5a1b0c63-0000-4000-8000-000000000000' + ]) { + replay.connection.handlers.onMessage?.({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state: 'cancelled', + uuid: `lifecycle-${commandUuid}`, + session_id: PROVIDER_SESSION_ID + }) + } + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) +}) + +describe('a Stop that withdraws the follow-up one at a time (no cancel_queued)', () => { + it.each([ + [ + 'times out', + () => { + throw new ClaudeControlRequestError('cancel_async_message', 'Control request timed out') + } + ], + [ + 'errors', + () => { + throw new Error('Query closed before response received') + } + ], + ['answers false', () => false] + ] as const)( + 'withdraws it from the CLI’s cancelled frame when cancel_async_message %s', + async (_label, answer) => { + const replay = await replayCapture('cancel-async', { + withoutCapabilities: ['interrupt_cancel_queued_v1'], + stopAfterControl: true, + atControl: async (point) => { + const queued = point.liveUuid('client-B') + point.routes.interrupt = () => ({ still_queued: [queued] }) + point.routes.cancel_async_message = () => { + point.deliverInFlight() + return answer() + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } + }) + + expect(replay.connection.calls.map((call) => call.subtype)).toContain('cancel_async_message') + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) +}) + +describe('a queued send the CLI withdraws without Orca hearing why', () => { + it('settles the withdrawn send from its own cancelled frame', async () => { + const replay = await replayCapture('cancel-async') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('withdraws only the batch lead; the send behind it runs as its own turn and is accepted', async () => { + const replay = await replayCapture('batch-lead') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + expect([...replay.turnStates.entries()]).toEqual([ + [replay.liveUuid('client-A'), 'completed'], + [replay.liveUuid('client-C'), 'completed'] + ]) + }) +}) + +describe('a send the CLI started, then cancelled', () => { + it('stays accepted when its turn failed after the echo', async () => { + const replay = await replayCapture('auth-failed') + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('is not read as withdrawn when the cancelled frame came before any echo', async () => { + // The auth-failed capture with the turn's output removed: started, then cancelled. + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) + + it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + afterFrame: (frame) => + frame.type === 'command_lifecycle' && frame.state === 'started' + ? [{ ...frame, state: 'queued' }] + : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) +}) + +describe('the CLI reporting its session idle', () => { + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'is reported once per idle frame (%s), never for running', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.idles).toEqual(['session-1']) + } + ) + + it('is not reported from a child the session no longer holds', async () => { + const replay = await replayCapture('auth-failed') + await replay.adapter.closeSession('session-1') + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'late-idle', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.idles).toEqual(['session-1']) + }) +}) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts new file mode 100644 index 00000000000..652a431d538 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.ts @@ -0,0 +1,33 @@ +// What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. +// +// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends +// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command +// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so +// it settles the send even when the interrupt or cancel_async_message answer is lost. + +import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { readClaudeFrameString } from './claude-structured-init-proof' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' +import type { ClaudeSession } from './claude-structured-session-state' + +export function observeClaudeCommandLifecycle( + session: ClaudeSession, + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const commandUuid = readClaudeFrameString(message, 'command_uuid') + // An echoed send has left both lists, so nothing here can reach a delivered message. + const waiter = [...session.dispatchWaiters, ...session.retiredDispatchWaiters].find( + (candidate) => candidate.sentUuid === commandUuid + ) + if (!waiter) { + return + } + const state = message.state + if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { + // Forward only: a redelivered command re-emits `queued`, but it has still started. + waiter.commandLifecycle = state + } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { + settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } +} diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts new file mode 100644 index 00000000000..d777e8a423d --- /dev/null +++ b/src/main/claude/claude-command-turn.test.ts @@ -0,0 +1,367 @@ +// A `/compact` as the Claude translator's open turn, driven by real captured frames: which of them +// end the command, how, and what the timeline draws. + +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CAPTURED_COMPACT_SESSION_ID, + CAPTURED_COMPACT_STOPPED_THEN_SEND, + CAPTURED_COMPACT_SUCCEEDS, + type CapturedCompactEvent +} from './claude-captured-compact-frames.test-fixture' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const COMMAND_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_KEY = agentJournalItemKey(COMMAND_IDENTITY) +const IN_COMMAND: AgentJournalTurnScope = { kind: 'turn', turnItemId: COMMAND_KEY } +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 900, + startedAt: 1_000 +} + +type Row = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** A sink over a journal that holds the host's running command turn, revising rows in place. */ +function harness() { + const rows = new Map([[COMMAND_KEY, { key: COMMAND_KEY, body: RUNNING }]]) + const writes: Row[] = [] + const write = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => { + const row = { key: agentJournalItemKey(identity), body, ...(turnScope ? { turnScope } : {}) } + rows.set(row.key, row) + writes.push(row) + } + const journal: StructuredAgentSessionRevisionJournal = { + epoch: 'epoch-1', + itemBody: (itemId) => rows.get(itemId)?.body ?? null, + visitItems: (visit) => { + let sequence = 0 + for (const row of rows.values()) { + visit(row.key, sequence++, row.body) + } + } + } + const revise: NonNullable = ( + _bytes, + resolve, + options + ) => { + const resolved = resolve(journal) + if (resolved) { + write(resolved.identity, resolved.body, options.turnScope) + } + return { accepted: true } + } + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => write(identity, body, options.turnScope), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryReviseResolvedItem: revise, + tryReviseResolvedItemAndPublish: revise + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const frame = (message: Record, extra: Record = {}) => + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { session_id: CAPTURED_COMPACT_SESSION_ID, ...message }, + observedAt: 5_000, + ...extra + }) + const begin = (sentUuid: string) => + translator.beginCommand({ + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_IDENTITY, + resultIdentity: { provider: 'orca', clientMessageId: 'command-result:cmd-1' }, + running: RUNNING, + providerSessionId: CAPTURED_COMPACT_SESSION_ID, + sentUuid + }) + /** Replays a capture as the adapter delivers it: `/compact` goes through `beginCommand`, and a + * Stop marks the interrupt the way the cancel path does. */ + const replay = (events: readonly CapturedCompactEvent[]) => { + for (const event of events) { + if ('sent' in event) { + if (event.sent.text === '/compact') { + begin(event.sent.uuid) + } + } else if ('interrupt' in event) { + translator.commandInterruptRequested(RUNNING.turnId) + } else { + frame(event.frame) + } + } + } + const commandTurn = () => readAgentJournalTurn(rows.get(COMMAND_KEY)?.body) + const drawn = () => + [...rows.values()].filter((row) => row.key !== COMMAND_KEY && row.body.kind !== 'turn') + return { translator, rows, writes, frame, begin, replay, commandTurn, drawn } +} + +describe('a captured /compact as the open Claude turn', () => { + it('ends a finished compaction as a success, drawing only the compaction separator', () => { + const { replay, commandTurn, drawn, translator } = harness() + replay(CAPTURED_COMPACT_SUCCEEDS) + + expect(commandTurn()).toMatchObject({ + turnId: RUNNING.turnId, + state: 'completed', + outcome: 'success', + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt, + startedAt: RUNNING.startedAt + }) + // Not the continuation summary, not the command's echo, not a lifecycle opcode row. + expect(drawn()).toEqual([ + { + key: agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-result:cmd-1' }), + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: IN_COMMAND + } + ]) + expect(translator.commandTurnId).toBeNull() + }) + + it("ends a stopped compaction as the user's cancellation, then answers the next send in its own turn", () => { + const { replay, commandTurn, drawn, rows } = harness() + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND) + + // Claude's result for the stopped command is `success` like a finished one's. + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + const answer = drawn().find((row) => row.body.kind === 'message') + expect(answer?.body).toMatchObject({ role: 'assistant' }) + expect(answer?.turnScope).not.toEqual(IN_COMMAND) + const answered = readAgentJournalTurn( + answer?.turnScope?.kind === 'turn' ? rows.get(answer.turnScope.turnItemId)?.body : undefined + ) + expect(answered).toMatchObject({ state: 'completed', outcome: 'success' }) + // "Compaction canceled." is the command's own output, and a stop earns no failure row. + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('reads a compaction with no boundary and no stop as a failure, with Claude’s reason', () => { + const { replay, commandTurn, drawn } = harness() + // The stopped capture without Orca's stop: Claude reported the compaction failed. + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND.filter((event) => !('interrupt' in event))) + + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + drawn().filter( + (row) => row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === COMMAND_KEY + ) + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: API Error: Request was aborted.', + failure: { + kind: 'compactionFailed', + detail: { text: 'API Error: Request was aborted.', audience: 'person' } + }, + tone: 'error' + } + }) + ]) + }) + + it('reads a stop that lands after the summary but before the boundary as a cancellation', () => { + const { replay, commandTurn, drawn } = harness() + // Only the boundary says the conversation was replaced; the status that precedes it does not. + replay( + CAPTURED_COMPACT_SUCCEEDS.flatMap((event): CapturedCompactEvent[] => + 'frame' in event && event.frame.subtype === 'compact_boundary' + ? [] + : 'frame' in event && event.frame.compact_result === 'success' + ? [event, { at: event.at, interrupt: true }] + : [event] + ) + ) + + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('leaves the command running at a result that names another input', () => { + const { begin, frame, commandTurn, translator } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ type: 'result', subtype: 'success', is_error: false, user_message_uuid: 'earlier' }) + expect(commandTurn()?.state).toBe('running') + expect(translator.commandTurnId).toBe(RUNNING.turnId) + + frame({ + type: 'result', + subtype: 'success', + is_error: false, + user_message_uuid: 'compact-input' + }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'success' }) + }) +}) + +describe('the command turn at each point the ordinary result path threads through', () => { + it('suppresses a provider reopen after a command that failed, as after any failed turn', () => { + const failed = harness() + failed.begin('compact-input') + failed.frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + failed.frame({ + type: 'assistant', + uuid: 'stray', + parent_tool_use_id: null, + message: { id: 'msg-stray', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(failed.translator.currentTurnId).toBeNull() + + const finished = harness() + finished.begin('compact-input') + finished.frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + finished.frame({ type: 'result', subtype: 'success', is_error: false }) + finished.frame({ + type: 'assistant', + uuid: 'resumed', + parent_tool_use_id: null, + message: { id: 'msg-resumed', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(finished.translator.currentTurnId).not.toBeNull() + }) + + it("settles a child still working in the command's turn when the command ends", () => { + const { begin, frame, rows } = harness() + begin('compact-input') + frame({ + type: 'system', + subtype: 'task_started', + task_id: 'task-1', + task_type: 'local_agent', + description: 'Map the lane' + }) + frame({ type: 'result', subtype: 'success', is_error: false }) + const group = rows.get( + agentJournalItemKey({ + provider: 'orca', + clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}` + }) + ) + const agents = + group?.body.kind === 'message' + ? group.body.blocks.flatMap((block) => + block.type === 'subagent-group' ? block.agents : [] + ) + : [] + expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it("records the context window the command's result reports on the command's turn", () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'init', model: 'claude-opus-5-5[1m]', uuid: 'init' }) + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ + type: 'result', + subtype: 'success', + is_error: false, + modelUsage: { 'claude-opus-5-5[1m]': { contextWindow: 1_000_000 } } + }) + expect(commandTurn()).toMatchObject({ + state: 'completed', + contextUsage: { window: { tokens: 1_000_000 } } + }) + }) + + it("reports a compaction Claude refused in Claude's own words, and one with none plainly", () => { + const worded = harness() + worded.begin('compact-input') + worded.frame({ + type: 'system', + subtype: 'status', + compact_result: 'failed', + compact_error: 'Not enough messages to compact.' + }) + worded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(worded.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(worded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + }, + tone: 'error' + } + ]) + + const unworded = harness() + unworded.begin('compact-input') + unworded.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) + unworded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(unworded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a result with no compaction and no failure as a compaction Claude never confirmed', () => { + const { begin, frame, commandTurn, drawn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'success', is_error: false }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('fails a command whose result ended in error', () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it("draws one error row, the provider's, for a command whose result is an error", () => { + const { begin, frame, drawn } = harness() + begin('compact-input') + frame({ + type: 'result', + subtype: 'success', + is_error: true, + result: 'API Error: 529 upstream overloaded' + }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ text: 'API Error: 529 upstream overloaded' }), + turnScope: IN_COMMAND + }) + ]) + }) +}) diff --git a/src/main/claude/claude-command-turn.ts b/src/main/claude/claude-command-turn.ts new file mode 100644 index 00000000000..be846dc7858 --- /dev/null +++ b/src/main/claude/claude-command-turn.ts @@ -0,0 +1,153 @@ +// A conversation command Claude runs as the session's open turn. The turn is the host's record, and +// the command's own result ends it. Everything Claude writes for the command meanwhile — the +// summary it continues from, the command's echo, a "Compaction canceled." — is the command's +// output, never a reply, so none of it draws. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { claudeText } from './claude-structured-item-translation' +import { claudeResultFailure } from './claude-structured-provider-fallback' +import type { ClaudeCurrentTurn, ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { isRootClaudeFrame } from './claude-turn-opening' +import type { ClaudeOpenTurn } from './claude-open-turn' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +export type ClaudeCommandTurn = { + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The command input's uuid, which the result answering it names. */ + sentUuid: string + compacted: boolean + /** Orca asked Claude to stop the command. */ + interruptRequested: boolean + /** Claude reported the compaction failed, with its words for a person when it gave any. */ + failed: { detail?: ProviderDiagnostic } | null +} + +export type ClaudeCommandStart = StructuredAgentSessionCommandRun & { + providerSessionId: string + sentUuid: string +} + +export function claudeCommandCurrentTurn(start: ClaudeCommandStart): ClaudeCurrentTurn { + const { running } = start + return { + sessionId: start.providerSessionId, + turnId: start.turnId, + startedAt: running.startedAt ?? Date.now(), + ...(running.requestedAt === undefined ? {} : { requestedAt: running.requestedAt }), + userItemId: running.userItemId ?? agentJournalItemKey(start.identity), + command: { + identity: start.identity, + resultIdentity: start.resultIdentity, + sentUuid: start.sentUuid, + compacted: false, + interruptRequested: false, + failed: null + } + } +} + +/** Reads a running command's evidence off a frame. True for the command's own output, which + * draws nothing. */ +export function observeClaudeCommandFrame( + command: ClaudeCommandTurn | null, + message: Record +): boolean { + if (!command) { + return false + } + if (message.type === 'system') { + // Only the boundary says the history was replaced; `compact_result: 'success'` precedes it. + if (message.subtype === 'compact_boundary') { + command.compacted = true + } else if (message.compact_result === 'failed') { + const words = claudeText(message.compact_error) + const detail = words === null ? undefined : providerDiagnostic(words, 'person') + command.failed = detail ? { detail } : {} + } + return false + } + return ( + isRootClaudeFrame(message) && + (message.type === 'user' || message.type === 'assistant' || message.type === 'stream_event') + ) +} + +/** The command's end from a root result, and the one row that reports it; null when the result + * names another input. */ +export function claudeCommandEnd( + command: ClaudeCommandTurn, + message: Record, + completedAt: number +): { end: ClaudeTurnEnd; row: AgentJournalItemBody | null } | null { + const answers = claudeText(message.user_message_uuid) + if (answers !== null && answers !== command.sentUuid) { + return null + } + // A stopped `/compact` ends in the same success result as a finished one: only the provider's + // report of the compaction tells them apart. + const shown = claudeResultFailure(message) + const verdict = structuredCompactionOutcome({ + compacted: command.compacted, + interruptRequested: command.interruptRequested, + failed: command.failed ?? (shown ? {} : null) + }) + const durationMs = message.duration_ms + const end: ClaudeTurnEnd = { + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + completedAt, + outcome: verdict.outcome, + ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 + ? { durationMs } + : {}) + } + if (verdict.outcome === 'success') { + return { end, row: { kind: 'status', text: 'Context compacted', presentation: 'compaction' } } + } + // An error result already draws its own row through the provider fallback. + return verdict.failure && !shown + ? { + end, + row: { + kind: 'status', + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.claude + }), + tone: 'error' + } + } + : { end, row: null } +} + +/** A root result, when a command is the open turn: the command's end, with its one result row + * already written; `another-input` for a result that answers something else; null when no + * command is open. */ +export function claudeCommandResultEnd( + turn: Pick, + sink: Pick, + message: Record, + completedAt: number +): ClaudeTurnEnd | 'another-input' | null { + const { command } = turn + if (!command) { + return null + } + const ended = claudeCommandEnd(command, message, completedAt) + if (!ended) { + return 'another-input' + } + if (ended.row) { + sink.appendItem(command.resultIdentity, ended.row, { turnScope: turn.turnScope }) + } + return ended.end +} diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index cb0dff927c5..4359799272e 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { selectStructuredAgentContextUsage } from '../../shared/structured-agent-session-context-usage' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { settleStaleStructuredAgentSessionState } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -106,7 +106,7 @@ async function openJournal(): Promise { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }, now: () => 9_000, - journalDir: join(root, 'orca-session') + stateDirectory: join(root, 'orca-session') }) } diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index b562c839efd..ed76a788c18 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -14,7 +14,7 @@ import { type StructuredAgentSessionState } from '../../shared/structured-agent-session-reducer' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -51,7 +51,7 @@ async function openJournal(): Promise { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }, now: () => 9_000, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) } diff --git a/src/main/claude/claude-context-usage.ts b/src/main/claude/claude-context-usage.ts index 010620cf09d..43e1ef5c31e 100644 --- a/src/main/claude/claude-context-usage.ts +++ b/src/main/claude/claude-context-usage.ts @@ -10,7 +10,7 @@ import { type AgentSessionTokenUsage } from '../../shared/agent-session-context-usage' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' /** A report later than this describes a context the user has likely moved past. */ export const CLAUDE_CONTEXT_USAGE_TIMEOUT_MS = 5_000 diff --git a/src/main/claude/claude-folder-trust-file.test.ts b/src/main/claude/claude-folder-trust-file.test.ts new file mode 100644 index 00000000000..1c876be1457 --- /dev/null +++ b/src/main/claude/claude-folder-trust-file.test.ts @@ -0,0 +1,314 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + applyClaudeFolderTrust, + grantClaudeFolderTrust, + grantClaudeWorkspaceTrust, + resolveClaudeGlobalConfigFile, + resolveLocalClaudeTrustConfig, + toClaudeTrustKey +} from './claude-folder-trust-file' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' + +let root: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-trust-'))) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function writeConfig(file: string, value: unknown, mode = 0o600): void { + writeFileSync(file, JSON.stringify(value), { mode }) + chmodSync(file, mode) +} + +function readConfig(file: string): Record { + return JSON.parse(readFileSync(file, 'utf-8')) +} + +describe('toClaudeTrustKey', () => { + it('NFC-normalises so a decomposed path matches the key Claude looks up', () => { + const decomposed = '/tmp/cafe\u0301' + expect(toClaudeTrustKey(decomposed, 'posix')).toBe('/tmp/caf\u00e9') + }) + + it('uses forward slashes on Windows, as Claude does', () => { + expect(toClaudeTrustKey('C:\\Users\\me\\wt\\', 'win32')).toBe('C:/Users/me/wt/') + expect(toClaudeTrustKey('C:\\Users\\me\\.\\wt', 'win32')).toBe('C:/Users/me/wt') + }) +}) + +describe('resolveClaudeGlobalConfigFile', () => { + const none = (): boolean => false + + it('defaults to ~/.claude.json, never ~/.claude/.claude.json', () => { + expect( + resolveClaudeGlobalConfigFile({ env: {}, homeDir: '/home/u', style: 'posix', exists: none }) + ).toBe('/home/u/.claude.json') + }) + + it('uses CLAUDE_CONFIG_DIR/.claude.json when set', () => { + expect( + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: '/cfg' }, + homeDir: '/home/u', + style: 'posix', + exists: none + }) + ).toBe('/cfg/.claude.json') + }) + + it('prefers the legacy .config.json in the config dir', () => { + expect( + resolveClaudeGlobalConfigFile({ + env: {}, + homeDir: '/home/u', + style: 'posix', + exists: (p) => p === '/home/u/.claude/.config.json' + }) + ).toBe('/home/u/.claude/.config.json') + }) + + it('follows the custom-OAuth file suffix', () => { + expect( + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://x' }, + homeDir: '/home/u', + style: 'posix', + exists: none + }) + ).toBe('/home/u/.claude-custom-oauth.json') + }) +}) + +describe('applyClaudeFolderTrust', () => { + it('is a no-op when the folder is already trusted', () => { + expect( + applyClaudeFolderTrust({ projects: { '/wt': { hasTrustDialogAccepted: true } } }, ['/wt']) + ).toEqual({ kind: 'unchanged' }) + }) + + it('refuses a non-object projects map instead of replacing it', () => { + expect(applyClaudeFolderTrust({ projects: [] }, ['/wt'])).toEqual({ kind: 'refuse' }) + }) + + it("keeps Claude's own fields on an existing untrusted entry", () => { + expect( + applyClaudeFolderTrust({ projects: { '/wt': { allowedTools: ['x'] } } }, ['/wt']) + ).toEqual({ + kind: 'changed', + config: { projects: { '/wt': { allowedTools: ['x'], hasTrustDialogAccepted: true } } } + }) + }) +}) + +describe('grantClaudeFolderTrust', () => { + it('merges the key and keeps every other field', async () => { + const file = join(root, '.claude.json') + writeConfig(file, { + oauthAccount: { emailAddress: 'x' }, + mcpServers: { a: {} }, + projects: { '/elsewhere': { hasTrustDialogAccepted: true, allowedTools: [] } } + }) + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'granted' + ) + expect(readConfig(file)).toEqual({ + oauthAccount: { emailAddress: 'x' }, + mcpServers: { a: {} }, + projects: { + '/elsewhere': { hasTrustDialogAccepted: true, allowedTools: [] }, + '/wt': { hasTrustDialogAccepted: true } + } + }) + }) + + it('keeps an owner-only mode through the rewrite', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}, 0o600) + await grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] }) + expect(statSync(file).mode & 0o777).toBe(0o600) + }) + + it('never creates a missing config file', async () => { + const file = join(root, '.claude.json') + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'missing-config' + ) + expect(existsSync(file)).toBe(false) + }) + + it('leaves a corrupt file byte-for-byte untouched', async () => { + const file = join(root, '.claude.json') + writeFileSync(file, '{"oauthAccount": ', { mode: 0o600 }) + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'unreadable' + ) + expect(readFileSync(file, 'utf-8')).toBe('{"oauthAccount": ') + }) + + it('does nothing while Claude holds its lock, and leaves the lock in place', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}) + const lockDir = `${file}.lock` + mkdirSync(lockDir) + const oldTime = new Date(Date.now() - 60_000) + // Why: a lock older than Claude's 10 s stale window must still not be broken by Orca. + utimesSync(lockDir, oldTime, oldTime) + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'locked' + ) + expect(readConfig(file)).toEqual({}) + expect(existsSync(lockDir)).toBe(true) + }) + + it('updates a symlinked config through its target and keeps the link', async () => { + const target = join(root, 'dotfiles-claude.json') + const link = join(root, '.claude.json') + writeConfig(target, { theme: 'dark' }) + symlinkSync(target, link) + await grantClaudeFolderTrust({ configFile: link, folderKeys: ['/wt'] }) + expect(lstatSync(link).isSymbolicLink()).toBe(true) + expect(readConfig(target)).toEqual({ + theme: 'dark', + projects: { '/wt': { hasTrustDialogAccepted: true } } + }) + }) + + it('leaves the parent directory mode alone', async () => { + const home = join(root, 'home') + mkdirSync(home, { mode: 0o755 }) + chmodSync(home, 0o755) + const file = join(home, '.claude.json') + writeConfig(file, {}) + await grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] }) + expect(statSync(home).mode & 0o777).toBe(0o755) + }) + + it('grants every folder of a launch burst instead of losing some to its own lock', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}) + const keys = Array.from({ length: 12 }, (_, index) => `/wt-${index}`) + const outcomes = await Promise.all( + keys.map((key) => grantClaudeFolderTrust({ configFile: file, folderKeys: [key] })) + ) + expect(outcomes).toEqual(keys.map(() => 'granted')) + expect(readConfig(file).projects).toEqual( + Object.fromEntries(keys.map((key) => [key, { hasTrustDialogAccepted: true }])) + ) + }) +}) + +describe('grantClaudeWorkspaceTrust', () => { + it('writes the given folder and its realpath form, whatever kind of folder it is', async () => { + const real = join(root, 'real-folder') + mkdirSync(real) + const link = join(root, 'linked-folder') + symlinkSync(real, link) + const file = join(root, '.claude.json') + writeConfig(file, {}) + await expect( + grantClaudeWorkspaceTrust({ configFile: file, keyStyle: 'posix' }, link) + ).resolves.toBe('granted') + expect(readConfig(file)).toEqual({ + projects: { + [link]: { hasTrustDialogAccepted: true }, + [real]: { hasTrustDialogAccepted: true } + } + }) + }) + + it('does not take the lock when the folder is already trusted', async () => { + const file = join(root, '.claude.json') + writeConfig(file, { projects: { [root]: { hasTrustDialogAccepted: true } } }) + mkdirSync(`${file}.lock`) + await expect( + grantClaudeWorkspaceTrust({ configFile: file, keyStyle: 'posix' }, root) + ).resolves.toBe('unchanged') + }) + + it('maps a host path to the path Claude sees and skips one it cannot map', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}) + await grantClaudeWorkspaceTrust( + { configFile: file, keyStyle: 'posix', toClaudePath: () => '/home/u/wt' }, + join(root, 'missing') + ) + expect(readConfig(file)).toEqual({ + projects: { '/home/u/wt': { hasTrustDialogAccepted: true } } + }) + await expect( + grantClaudeWorkspaceTrust( + { configFile: file, keyStyle: 'posix', toClaudePath: () => null }, + join(root, 'other') + ) + ).resolves.toBe('unchanged') + }) +}) + +describe('resolveLocalClaudeTrustConfig', () => { + const wslAuth: ClaudeRuntimeAuthPreparation = { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/u/.claude', + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + + it('reads the final spawn env for a host launch', () => { + const target = resolveLocalClaudeTrustConfig({ + workspacePath: '/repo/wt', + env: { CLAUDE_CONFIG_DIR: '/cfg', HOME: '/home/u' }, + claudeAuth: null, + wslDistro: null + }) + expect(target?.configFile).toBe(join('/cfg', '.claude.json')) + }) + + it("never writes the Windows host's file for a WSL launch it cannot map to the guest", () => { + for (const args of [ + { claudeAuth: null, wslDistro: 'Ubuntu' }, + { claudeAuth: { ...wslAuth, wslLinuxConfigDir: null }, wslDistro: 'Ubuntu' }, + { claudeAuth: wslAuth, wslDistro: 'Ubuntu', workspacePath: 'C:\\repo\\wt' } + ]) { + expect( + resolveLocalClaudeTrustConfig({ + workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', + env: { HOME: '/home/win' }, + ...args + }) + ).toBeNull() + } + }) + + it("targets the guest's own file and Linux keys for a WSL launch", () => { + const target = resolveLocalClaudeTrustConfig({ + workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', + env: {}, + claudeAuth: wslAuth, + wslDistro: 'Ubuntu' + }) + expect(target?.keyStyle).toBe('posix') + expect(target?.toClaudePath?.('\\\\wsl.localhost\\Ubuntu\\home\\u\\wt')).toBe('/home/u/wt') + }) +}) diff --git a/src/main/claude/claude-folder-trust-file.ts b/src/main/claude/claude-folder-trust-file.ts new file mode 100644 index 00000000000..ca35351eeb0 --- /dev/null +++ b/src/main/claude/claude-folder-trust-file.ts @@ -0,0 +1,306 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + existsSync, + lstatSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, posix, resolve, win32 } from 'node:path' +import { homedir } from 'node:os' +import { lock } from 'proper-lockfile' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' + +export type ClaudeTrustPathStyle = 'posix' | 'win32' + +export type ClaudeFolderTrustOutcome = + | 'granted' + | 'unchanged' + | 'missing-config' + | 'locked' + | 'unreadable' + +type ClaudeConfigEnv = { + CLAUDE_CONFIG_DIR?: string + CLAUDE_CODE_CUSTOM_OAUTH_URL?: string +} + +// Why: Orca must never break a lock — a held one means "skip and let Claude ask". +// Large enough that proper-lockfile never judges it stale, small enough that its +// half-stale refresh timer stays inside setTimeout's 32-bit range. +const NEVER_STALE_MS = 2 ** 30 +const LOCK_RETRIES = { retries: 4, factor: 2, minTimeout: 50, maxTimeout: 250 } +// Why: concurrent grants in one process retry the file lock in lockstep, so a launch burst +// would lose most of them to `locked`; queue them so only Claude itself contends for the lock. +const grantQueueByConfigFile = new Map>() + +function pathApi(style: ClaudeTrustPathStyle): typeof posix { + return style === 'win32' ? win32 : posix +} + +/** Claude looks up NFC `path.normalize` output, with `/` separators on Windows. */ +export function toClaudeTrustKey(folderPath: string, style: ClaudeTrustPathStyle): string { + const normalized = pathApi(style).normalize(folderPath.normalize('NFC')) + return style === 'win32' ? normalized.replaceAll('\\', '/') : normalized +} + +/** + * Mirrors Claude Code's global config lookup: a legacy `/.config.json` + * wins, otherwise `.claude.json` sits in `CLAUDE_CONFIG_DIR` or the home directory. + */ +export function resolveClaudeGlobalConfigFile(args: { + env: ClaudeConfigEnv + homeDir: string + style: ClaudeTrustPathStyle + exists: (filePath: string) => boolean +}): string { + const { join } = pathApi(args.style) + const legacyDir = (args.env.CLAUDE_CONFIG_DIR ?? join(args.homeDir, '.claude')).normalize('NFC') + const legacyFile = join(legacyDir, '.config.json') + if (args.exists(legacyFile)) { + return legacyFile + } + const suffix = args.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ? '-custom-oauth' : '' + return join(args.env.CLAUDE_CONFIG_DIR || args.homeDir, `.claude${suffix}.json`) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export type ClaudeFolderTrustChange = + | { kind: 'unchanged' } + | { kind: 'refuse' } + | { kind: 'changed'; config: Record } + +export function applyClaudeFolderTrust( + config: Record, + folderKeys: readonly string[] +): ClaudeFolderTrustChange { + if (config.projects !== undefined && !isPlainObject(config.projects)) { + return { kind: 'refuse' } + } + const projects: Record = { ...config.projects } + const alreadyTrusted = folderKeys.some((key) => { + const entry = projects[key] + return isPlainObject(entry) && entry.hasTrustDialogAccepted === true + }) + if (alreadyTrusted) { + return { kind: 'unchanged' } + } + for (const key of folderKeys) { + const entry = projects[key] + projects[key] = isPlainObject(entry) + ? { ...entry, hasTrustDialogAccepted: true } + : { hasTrustDialogAccepted: true } + } + return { kind: 'changed', config: { ...config, projects } } +} + +function isMissingFileError(error: unknown): boolean { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return code === 'ENOENT' || code === 'ENOTDIR' +} + +type ConfigTarget = { kind: 'file'; path: string } | { kind: 'missing' } | { kind: 'unreadable' } + +/** Resolves a symlinked config to its target so the rename keeps the link intact. */ +function resolveConfigTarget(configFile: string): ConfigTarget { + try { + const entry = lstatSync(configFile) + const path = entry.isSymbolicLink() ? realpathSync(configFile) : configFile + return statSync(path).isFile() ? { kind: 'file', path } : { kind: 'unreadable' } + } catch (error) { + return { kind: isMissingFileError(error) ? 'missing' : 'unreadable' } + } +} + +/** Reads the config behind `target`, or says why it cannot be rewritten. */ +function readConfigAt( + target: ConfigTarget +): { path: string; config: Record } | 'missing-config' | 'unreadable' { + if (target.kind === 'missing') { + return 'missing-config' + } + if (target.kind === 'unreadable') { + return 'unreadable' + } + const config = readConfigObject(target.path) + return config ? { path: target.path, config } : 'unreadable' +} + +function readConfigObject(target: string): Record | null { + try { + const parsed: unknown = JSON.parse(readFileSync(target, 'utf-8')) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + +function writeConfigAtomically(target: string, config: Record): void { + const mode = statSync(target).mode & 0o777 + const tmpPath = `${target}.orca-trust-${randomUUID()}.tmp` + try { + writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf-8', mode }) + if (process.platform !== 'win32') { + // Why: umask may narrow the requested mode; the replacement must match the original exactly. + chmodSync(tmpPath, mode) + } + renameFileWithWindowsRetry(tmpPath, target) + } catch (error) { + rmSync(tmpPath, { force: true }) + throw error + } +} + +/** + * Sets `projects[].hasTrustDialogAccepted` in Claude's global config. Never + * creates the file, never breaks Claude's lock, and never rewrites a file it could + * not read and parse. + */ +export function grantClaudeFolderTrust(args: { + configFile: string + folderKeys: readonly string[] +}): Promise { + return runKeyedSerializedOperation(grantQueueByConfigFile, args.configFile, () => + grantClaudeFolderTrustNow(args) + ) +} + +async function grantClaudeFolderTrustNow(args: { + configFile: string + folderKeys: readonly string[] +}): Promise { + const probe = readConfigAt(resolveConfigTarget(args.configFile)) + if (typeof probe === 'string') { + return probe + } + // Why: most launches need nothing, so skip Claude's lock unless a write is due. + const planned = applyClaudeFolderTrust(probe.config, args.folderKeys).kind + if (planned !== 'changed') { + return planned === 'refuse' ? 'unreadable' : 'unchanged' + } + + let release: () => Promise + try { + release = await lock(args.configFile, { + // Why: Claude locks the literal `.lock`, not a realpath'd one. + lockfilePath: `${args.configFile}.lock`, + realpath: false, + stale: NEVER_STALE_MS, + retries: LOCK_RETRIES, + onCompromised: () => {} + }) + } catch { + return 'locked' + } + try { + // Why: read → rename stays synchronous so Orca's own synchronous auth writer to + // this file cannot interleave and lose an update. + const current = readConfigAt(resolveConfigTarget(args.configFile)) + if (typeof current === 'string') { + return current + } + const change = applyClaudeFolderTrust(current.config, args.folderKeys) + if (change.kind === 'refuse') { + return 'unreadable' + } + if (change.kind === 'unchanged') { + return 'unchanged' + } + writeConfigAtomically(current.path, change.config) + return 'granted' + } finally { + await release().catch(() => {}) + } +} + +/** Keys for `workspacePath` as Claude will see it: the given and realpath'd forms. */ +export function claudeTrustKeysForHostPath( + workspacePath: string, + keyStyle: ClaudeTrustPathStyle, + toClaudePath: (hostPath: string) => string | null = (hostPath) => hostPath +): string[] { + const forms = [resolve(workspacePath)] + try { + forms.push(realpathSync.native(workspacePath)) + } catch { + // The resolved form alone still matches an unsymlinked path. + } + const keys = new Set() + for (const form of forms) { + const claudePath = toClaudePath(form) + if (claudePath) { + keys.add(toClaudeTrustKey(claudePath, keyStyle)) + } + } + return [...keys] +} + +export type ClaudeTrustConfigTarget = { + configFile: string + keyStyle: ClaudeTrustPathStyle + /** Maps a host-native path to the path the Claude process sees (WSL UNC → Linux). */ + toClaudePath?: (hostPath: string) => string | null +} + +/** The config file a local or WSL-guest Claude will read, or null when Orca cannot tell. */ +export function resolveLocalClaudeTrustConfig(args: { + workspacePath: string + /** The final spawn env layered over this process's env. */ + env: Record + claudeAuth: ClaudeRuntimeAuthPreparation | null + wslDistro: string | null +}): ClaudeTrustConfigTarget | null { + const { claudeAuth } = args + if (claudeAuth?.runtime === 'wsl' || args.wslDistro || parseWslUncPath(args.workspacePath)) { + // Why: a WSL guest reads its own config, reachable only through the auth prep's UNC dir; + // without a guest config dir, the prep's `configDir` is the Windows host's own file. + if ( + claudeAuth?.runtime !== 'wsl' || + !claudeAuth.wslLinuxConfigDir || + !parseWslUncPath(args.workspacePath) + ) { + return null + } + const legacyFile = join(claudeAuth.configDir, '.config.json') + return { + configFile: existsSync(legacyFile) + ? legacyFile + : claudeAuth.envPatch.CLAUDE_CONFIG_DIR + ? join(claudeAuth.configDir, '.claude.json') + : join(dirname(claudeAuth.configDir), '.claude.json'), + keyStyle: 'posix', + toClaudePath: (hostPath) => parseWslUncPath(hostPath)?.linuxPath ?? null + } + } + const style = process.platform === 'win32' ? 'win32' : 'posix' + const homeDir = (style === 'win32' ? args.env.USERPROFILE : args.env.HOME) || homedir() + return { + configFile: resolveClaudeGlobalConfigFile({ + env: args.env, + homeDir, + style, + exists: existsSync + }), + keyStyle: style + } +} + +/** Grants trust for `workspacePath` in the config `target` names. */ +export async function grantClaudeWorkspaceTrust( + target: ClaudeTrustConfigTarget, + workspacePath: string +): Promise { + const folderKeys = claudeTrustKeysForHostPath(workspacePath, target.keyStyle, target.toClaudePath) + return folderKeys.length === 0 + ? 'unchanged' + : grantClaudeFolderTrust({ configFile: target.configFile, folderKeys }) +} diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 1ccf24e1393..45feeb84364 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -6,6 +6,7 @@ import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import type { ClaudeCommandStart } from './claude-command-turn' export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void @@ -13,6 +14,14 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** The open turn's id while it is a conversation command's. */ + readonly commandTurnId: string | null + /** Makes the host's command turn the open one until the command's result ends it. */ + beginCommand: (start: ClaudeCommandStart) => void + /** The command was never sent. */ + forgetCommand: (turnId: string) => void + /** Orca asked Claude to stop the command `turnId` names. */ + commandInterruptRequested: (turnId: string) => void /** True while a turn is open and the provider's current request cycle has * done root work since its init — the state in which the CLI folds an * arriving send into the turn. */ diff --git a/src/main/claude/claude-journal-turn-scope.test.ts b/src/main/claude/claude-journal-turn-scope.test.ts new file mode 100644 index 00000000000..fef833ced57 --- /dev/null +++ b/src/main/claude/claude-journal-turn-scope.test.ts @@ -0,0 +1,78 @@ +// Which turn a Claude row belongs to (B8): the root turn open when the row is written, whoever +// produced it, and the conversation once that turn has ended. + +import { expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionItemAppendOptions +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function recorder() { + const writes: { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions + }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => writes.push({ identity, body, options }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const scopeOfProse = (text: string) => + writes.findLast( + (write) => + write.body.kind === 'message' && + write.body.blocks.some((block) => block.type === 'text' && block.text === text) + )?.options.turnScope + return { sink, writes, scopeOfProse } +} + +function frame(message: Record, startsTurn = false) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(startsTurn ? { startsTurn: true as const } : {}), + message: { session_id: 'claude-session', ...message } + } +} + +function prose(uuid: string, text: string, parentToolUseId: string | null = null) { + return frame({ + type: 'assistant', + uuid, + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content: [{ type: 'text', text }] } + }) +} + +it("scopes the session's and a subagent's rows to the open turn, and a late one to none", () => { + const { sink, writes, scopeOfProse } = recorder() + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + translator.handle( + frame( + { + type: 'user', + uuid: 'user-1', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + }, + true + ) + ) + translator.handle(prose('assistant-1', 'Working on it')) + translator.handle(prose('child-1', 'Child mid-turn', 'toolu_1')) + translator.handle(frame({ type: 'result', subtype: 'success', uuid: 'result-1', result: 'ok' })) + translator.handle(prose('child-2', 'Child after the turn', 'toolu_1')) + + const turnRecord = writes.find((write) => write.body.kind === 'turn') + expect(turnRecord?.options.turnScope).toEqual({ kind: 'thread' }) + const turnKey = expect.stringContaining('turn-lifecycle') + expect(scopeOfProse('Working on it')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child mid-turn')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child after the turn')).toEqual({ kind: 'thread' }) +}) diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 8a9dc30cd5a..864624fe59e 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -5,15 +5,21 @@ // keeping a copy, so there is nothing to disagree with. import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { - claudeTurnLifecycleIdentity, + claudeCurrentTurnIdentity, claudeTurnLifecycleItem, type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { writeClaudeTurnRow } from './claude-turn-row-revision' +import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' export type ClaudeOpenTurnDeps = { @@ -55,9 +61,21 @@ export class ClaudeOpenTurn { /** The open turn's row, where a fact about the running turn lands. */ get identity(): AgentJournalItemIdentity | null { - return this.current - ? claudeTurnLifecycleIdentity(this.current.sessionId, this.current.turnId) - : null + return this.current ? claudeCurrentTurnIdentity(this.current) : null + } + + /** The conversation command the open turn is, if it is one. */ + get command(): ClaudeCommandTurn | null { + return this.current?.command ?? null + } + + /** Which turn a row written now belongs to: the open one, or none. A subagent's rows too — + * its work is its parent turn's. */ + get turnScope(): AgentJournalTurnScope { + const identity = this.identity + return identity + ? { kind: 'turn', turnItemId: agentJournalItemKey(identity) } + : AGENT_JOURNAL_THREAD_SCOPE } get groupKey(): string | null { @@ -99,6 +117,32 @@ export class ClaudeOpenTurn { this.deps.sink.setActivity?.(null) } + /** A conversation command the host opened a turn for. Its row is the host's, already written, + * so only an end is published; the command's result is what ends it. */ + beginCommand(turn: ClaudeCurrentTurn): void { + this.deps.onOpen?.() + if (this.current) { + this.deps.settleChildren(this.groupKey) + this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + } + this.current = turn + this.deps.sink.setActivity?.(null) + } + + /** Orca asked the provider to stop the command `turnId` names. */ + commandInterruptRequested(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current.command.interruptRequested = true + } + } + + /** The command was never sent; its turn is the host's to settle. */ + forgetCommand(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current = null + } + } + /** The provider produced, so a turn is running. Idempotent: every frame of one * reply stays inside the turn its first frame opened. A subagent's output is * its parent turn's work and never a turn of its own. */ diff --git a/src/main/claude/claude-prompt-journaling.ts b/src/main/claude/claude-prompt-journaling.ts deleted file mode 100644 index 6d3bfe148cf..00000000000 --- a/src/main/claude/claude-prompt-journaling.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Journaling an approval or question prompt, and remembering the rows it wrote -// so a cancellation can tombstone exactly those. - -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - claudeApprovalItem, - claudePromptIdentity, - claudeQuestionItems -} from './claude-structured-prompt-items' - -export type ClaudePromptJournalDeps = { - sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string) => void - /** Prompt key → the rows it wrote, owned by the translator so a cancel can sweep them. */ - promptItems: Map -} - -export function journalClaudePrompt( - deps: ClaudePromptJournalDeps, - event: Extract -): void { - const identities: AgentJournalItemIdentity[] = [] - if (event.prompt.kind === 'question') { - for (const question of claudeQuestionItems({ - sessionId: event.sessionId, - prompt: event.prompt - })) { - identities.push(question.identity) - deps.sink.appendItem(question.identity, question.body) - deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) - } - } else { - const identity = claudePromptIdentity({ - sessionId: event.sessionId, - promptKey: event.prompt.promptKey - }) - identities.push(identity) - deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) - deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) - } - deps.promptItems.set(event.prompt.promptKey, identities) - deps.sink.publish() -} diff --git a/src/main/claude/claude-provisional-row-corrections.test.ts b/src/main/claude/claude-provisional-row-corrections.test.ts index d18a71a17fe..cb317c5ca86 100644 --- a/src/main/claude/claude-provisional-row-corrections.test.ts +++ b/src/main/claude/claude-provisional-row-corrections.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody, @@ -38,6 +39,7 @@ function ledger(initial: Record = {}) { : { kind: 'linked', linkage: { agentId: ref, providerParentRef: ref, producerKind: 'agent' } } } const corrections = new ClaudeProvisionalRowCorrections({ + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE, linkageFor: (ref) => verdicts.get(ref) ?? { kind: 'pending' }, settledLinkageFor: (ref) => { const verdict = settledFor(ref) @@ -71,9 +73,11 @@ function ledger(initial: Record = {}) { } describe('ClaudeProvisionalRowCorrections', () => { - it("stamps the session's own rows with nothing and owes them nothing", () => { + it("stamps the session's own rows with no producer and owes them nothing", () => { const { corrections, rewrites } = ledger() - expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({}) + expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(corrections.pending).toBe(0) corrections.retry() expect(rewrites).toEqual([]) diff --git a/src/main/claude/claude-provisional-row-corrections.ts b/src/main/claude/claude-provisional-row-corrections.ts index a4b7d039902..92064e23f02 100644 --- a/src/main/claude/claude-provisional-row-corrections.ts +++ b/src/main/claude/claude-provisional-row-corrections.ts @@ -21,9 +21,11 @@ import { agentJournalLinkageFields } from '../../shared/agent-session-journal-pr import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' /** @@ -36,12 +38,7 @@ import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' export type ClaudeRowStamp = ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody -) => StructuredAgentSessionAppendOptions - -/** The session's own agent wrote this row: nothing is stamped, nothing is owed. - * Only for a site with no ledger to consult; a ledger-backed root write goes - * through `stampFor(null)`, which also supersedes anything owed to the row. */ -export const rootClaudeRowStamp: ClaudeRowStamp = () => ({}) +) => StructuredAgentSessionItemAppendOptions /** Corrections outstanding at once, across every producer. */ const MAX_OUTSTANDING_CORRECTIONS = 256 @@ -57,17 +54,19 @@ type OutstandingCorrection = { * a tool call and its result do — and a correction carrying the older body * would revert the row it is only meant to re-attribute. */ body: AgentJournalItemBody - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions } export type ClaudeProvisionalRowCorrectionsDeps = ClaudeSubagentLinkageSource & { + /** The turn a row written now belongs to: the open root turn, whoever produced the row. */ + turnScope: () => AgentJournalTurnScope /** Re-appends a row under its own identity, which revises it in place. * Returns whether the write was ADMITTED: a sink under backpressure refuses, * and a correction dropped on a refusal is an obligation nothing re-derives. */ rewrite: ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ) => boolean publish: () => void } @@ -92,12 +91,12 @@ export class ClaudeProvisionalRowCorrections { if (parentToolUseId === null) { return (identity) => { this.supersede(identity) - return {} + return { turnScope: this.deps.turnScope() } } } return (identity, body) => { const provisional = this.deps.linkageFor(parentToolUseId).kind === 'pending' - const options = this.stamp(parentToolUseId) + const options = { ...this.stamp(parentToolUseId), turnScope: this.deps.turnScope() } if (provisional) { this.remember(parentToolUseId, identity, body, options) } else { @@ -160,7 +159,7 @@ export class ClaudeProvisionalRowCorrections { this.outstanding.delete(agentJournalItemKey(identity)) } - private stamp(parentToolUseId: string): StructuredAgentSessionAppendOptions { + private stamp(parentToolUseId: string): ReturnType { return agentJournalLinkageFields(this.deps.settledLinkageFor(parentToolUseId).linkage) } @@ -171,14 +170,16 @@ export class ClaudeProvisionalRowCorrections { if (sameLinkage(options, correction.stamped)) { return 'unchanged' } - return this.deps.rewrite(correction.identity, correction.body, options) ? 'wrote' : 'refused' + // A re-stamp revises the row, so its scope stays the one it was created with. + const restamped = { ...options, turnScope: correction.stamped.turnScope } + return this.deps.rewrite(correction.identity, correction.body, restamped) ? 'wrote' : 'refused' } private remember( ref: string, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions ): void { if (this.givenUp.has(ref)) { return @@ -233,8 +234,8 @@ export class ClaudeProvisionalRowCorrections { } function sameLinkage( - left: StructuredAgentSessionAppendOptions, - right: StructuredAgentSessionAppendOptions + left: AgentJournalProducerLinkage, + right: AgentJournalProducerLinkage ): boolean { return ( left.agentId === right.agentId && diff --git a/src/main/claude/claude-real-cli-availability-test-support.ts b/src/main/claude/claude-real-cli-availability-test-support.ts index faa322c456b..ecbce5c354f 100644 --- a/src/main/claude/claude-real-cli-availability-test-support.ts +++ b/src/main/claude/claude-real-cli-availability-test-support.ts @@ -1,5 +1,5 @@ -// Whether a real, signed-in Claude CLI is present — the gate every real-CLI -// suite skips on. Probed once per test process. +// Whether a real, signed-in Claude CLI may be used — the gate every real-CLI +// suite skips on. Opt-in only; probed once per test process. import { homedir } from 'node:os' import { join } from 'node:path' @@ -7,6 +7,7 @@ import { runProcessSync, type ProcessResult } from '../../shared/child-process/r import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CLAUDE_AUTH_ENV_VARS } from '../claude-accounts/environment' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveRealClaudeCliGate } from './claude-real-cli-test-gate' export const realClaudeCommand = resolveClaudeCommand() @@ -25,24 +26,18 @@ function probeRealClaude(args: string[]): ProcessResult | null { } } -export const realClaudeAvailable = probeRealClaude(['--version'])?.code === 0 +// Why the gate: an installed, signed-in CLI is not consent to spend turns on that account, so +// nothing is probed unless the run opted in. +export const realClaudeCliGate = resolveRealClaudeCliGate(process.env, (args) => { + const result = probeRealClaude([...args]) + return { status: result?.code ?? null, stdout: result?.stdout ?? '' } +}) + +export const realClaudeAvailable = realClaudeCliGate.skipReason === null /** The CLI's own account report — the only source of truth for where it writes that * is not derived from Orca's own path expressions. */ -export const realClaudeAuthStatus = (() => { - if (!realClaudeAvailable) { - return null - } - const result = probeRealClaude(['auth', 'status', '--json']) - if (!result || result.code !== 0) { - return null - } - try { - return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } - } catch { - return null - } -})() +export const realClaudeAuthStatus = realClaudeCliGate.authStatus export const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true diff --git a/src/main/claude/claude-real-cli-test-gate.test.ts b/src/main/claude/claude-real-cli-test-gate.test.ts new file mode 100644 index 00000000000..508fd6af08e --- /dev/null +++ b/src/main/claude/claude-real-cli-test-gate.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { + REAL_CLAUDE_CLI_TEST_ENV, + resolveRealClaudeCliGate, + type ClaudeCliProbeResult +} from './claude-real-cli-test-gate' + +/** A claude that is installed and signed in, as far as the probes can tell. */ +function signedInClaude() { + return vi.fn((args: readonly string[]): ClaudeCliProbeResult => { + if (args[0] === '--version') { + return { status: 0, stdout: '2.1.0 (Claude Code)\n' } + } + return { + status: 0, + stdout: JSON.stringify({ loggedIn: true, projectsDirectory: '/home/dev/.claude/projects' }) + } + }) +} + +describe('resolveRealClaudeCliGate', () => { + it.each([undefined, '', '0', 'true'])( + 'skips without probing a signed-in claude when %s is the opt-in value', + (value) => { + const runClaude = signedInClaude() + + const gate = resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: value }, runClaude) + + expect(gate).toEqual({ + skipReason: 'set ORCA_REAL_CLAUDE_CLI_TEST=1 to run against the real claude CLI', + authStatus: null + }) + expect(runClaude).not.toHaveBeenCalled() + } + ) + + it('runs with the CLI account report once opted in and signed in', () => { + const runClaude = signedInClaude() + + const gate = resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude) + + expect(gate).toEqual({ + skipReason: null, + authStatus: { loggedIn: true, projectsDirectory: '/home/dev/.claude/projects' } + }) + expect(runClaude.mock.calls).toEqual([[['--version']], [['auth', 'status', '--json']]]) + }) + + it('still skips when opted in but no claude binary answers', () => { + const runClaude = vi.fn((): ClaudeCliProbeResult => ({ status: null, stdout: '' })) + + const gate = resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude) + + expect(gate).toEqual({ skipReason: '`claude --version` failed', authStatus: null }) + expect(runClaude).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['a failed auth probe', { status: 1, stdout: '' }], + ['unparseable auth output', { status: 0, stdout: 'not json' }] + ])('runs with no account report after %s', (_label, authResult) => { + const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult => + args[0] === '--version' ? { status: 0, stdout: '2.1.0\n' } : authResult + ) + + // The suite's signed-out case still runs; its signed-in cases skip on a null report. + expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({ + skipReason: null, + authStatus: null + }) + }) + + it('keeps only the account fields it understands', () => { + const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult => + args[0] === '--version' + ? { status: 0, stdout: '2.1.0\n' } + : { status: 0, stdout: JSON.stringify({ loggedIn: 'yes', projectsDirectory: 7 }) } + ) + + expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({ + skipReason: null, + authStatus: {} + }) + }) +}) diff --git a/src/main/claude/claude-real-cli-test-gate.ts b/src/main/claude/claude-real-cli-test-gate.ts new file mode 100644 index 00000000000..de25ef87bf4 --- /dev/null +++ b/src/main/claude/claude-real-cli-test-gate.ts @@ -0,0 +1,52 @@ +/** Opt-in for tests that drive the real `claude` CLI on this machine's signed-in account. */ +export const REAL_CLAUDE_CLI_TEST_ENV = 'ORCA_REAL_CLAUDE_CLI_TEST' + +export type ClaudeCliProbeResult = { status: number | null; stdout: string } + +export type RealClaudeAuthStatus = { loggedIn?: boolean; projectsDirectory?: string } + +export type RealClaudeCliGate = { + /** Null when the real-CLI suite may run; otherwise why it is skipped. */ + skipReason: string | null + /** The CLI's own account report; null when skipped or unreadable. */ + authStatus: RealClaudeAuthStatus | null +} + +function parseAuthStatus(stdout: string): RealClaudeAuthStatus | null { + let parsed: unknown + try { + parsed = JSON.parse(stdout) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const status: RealClaudeAuthStatus = {} + if ('loggedIn' in parsed && typeof parsed.loggedIn === 'boolean') { + status.loggedIn = parsed.loggedIn + } + if ('projectsDirectory' in parsed && typeof parsed.projectsDirectory === 'string') { + status.projectsDirectory = parsed.projectsDirectory + } + return status +} + +export function resolveRealClaudeCliGate( + env: NodeJS.ProcessEnv, + runClaude: (args: readonly string[]) => ClaudeCliProbeResult +): RealClaudeCliGate { + // Why before any probe: an installed, signed-in CLI is not consent to spend turns on that + // account and write transcripts into its home from a broad local test run. + if (env[REAL_CLAUDE_CLI_TEST_ENV] !== '1') { + return { + skipReason: `set ${REAL_CLAUDE_CLI_TEST_ENV}=1 to run against the real claude CLI`, + authStatus: null + } + } + if (runClaude(['--version']).status !== 0) { + return { skipReason: '`claude --version` failed', authStatus: null } + } + const auth = runClaude(['auth', 'status', '--json']) + return { skipReason: null, authStatus: auth.status === 0 ? parseAuthStatus(auth.stdout) : null } +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index d34d0efc206..9db40feac49 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,8 +3,10 @@ // A replay or result is joined to its waiter by the client uuid Claude echoes. // A send Claude FOLDS into the running request cycle is replayed mid-cycle with // the client uuid adopted: once that cycle has done work, that replay is a -// delivery receipt and opens no boundary. +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its +// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -28,6 +30,10 @@ export function resolveClaudeReplayTurn( message: Record, onSettledLate?: ClaudeLateDispatchSettlement ): ClaudeReplayTurnOrigin | null { + if (message.type === 'command_lifecycle') { + observeClaudeCommandLifecycle(session, message, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts new file mode 100644 index 00000000000..14fd2e94eb6 --- /dev/null +++ b/src/main/claude/claude-result-journaling.ts @@ -0,0 +1,88 @@ +// Journaling ONE Claude `result` frame: the end of the root turn it settles, and the diagnostic +// row a failed or unrecognised result leaves. +// +// Split out of the translator when that file reached its line budget; the translator still owns +// the open turn and every collaborator this writes through. + +import type { ClaudeContextFacts } from './claude-context-facts' +import { claudeCommandResultEnd } from './claude-command-turn' +import type { ClaudeMessageJournalContext } from './claude-message-journaling' +import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { + claudeProviderFrameKind, + claudeResultFailure, + isSettledClaudeResultKind +} from './claude-structured-provider-fallback' +import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' +import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' + +export type ClaudeResultJournalContext = Pick< + ClaudeMessageJournalContext, + | 'sink' + | 'streamedBlocks' + | 'streamedText' + | 'subagents' + | 'providerFallback' + | 'corrections' + | 'turn' +> & { + prompts: ClaudeJournalPrompts + context: ClaudeContextFacts +} + +export function journalClaudeResult( + { + sink, + streamedBlocks, + streamedText, + subagents, + providerFallback, + corrections, + turn, + prompts, + context + }: ClaudeResultJournalContext, + message: Record, + observedAt: number +): void { + // Every turn this translator opens is root by construction, so a nested + // result settles the child that produced it and never the turn. The + // diagnostic below still runs: a child's failure is reportable even when + // it ends no turn. + const settlesTurn = isRootClaudeFrame(message) + const commandEnd = settlesTurn ? claudeCommandResultEnd(turn, sink, message, observedAt) : null + if (commandEnd === 'another-input') { + return + } + // Read before the settle below closes it: the result reports that turn's end. + const endedTurnScope = turn.turnScope + if (settlesTurn) { + prompts.retryPendingCancellations() + turn.suppressReopenOnFailure(message.is_error === true) + // The turn is over however it ended, so a foreground child still + // reported as working will never be settled by an event. + subagents.settleTurn(turn.groupKey) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + // The turn is over. A block still awaiting its final keeps the text the + // flush above journaled, but its live state goes: an interrupted turn + // would otherwise retain that text for the life of the session. + streamedBlocks.clear() + streamedText.settle() + } + const kind = claudeProviderFrameKind(message) + const failure = claudeResultFailure(message) + if (failure || !isSettledClaudeResultKind(kind)) { + providerFallback.append( + kind, + message, + failure?.text, + undefined, + undefined, + // A result that settles no turn is a CHILD's result: this + // translator only ever opens root turns. + settlesTurn + ? () => ({ turnScope: endedTurnScope }) + : corrections.stampFor(claudeFrameParentRef(message)) + ) + } +} diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index e3682f03306..7d198c5a5de 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -20,6 +20,7 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' // These drive the real SDK against the scripted fake CLI, so every assertion is // about the environment, argv and frames a real child actually saw. @@ -380,7 +381,7 @@ describe('Claude stream-json connection', () => { agent: 'claude', providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' } }, - journalDir: join(scenario.cwd, 'journal'), + database: openTestJournalHostDatabase(join(scenario.cwd, 'journal')), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) diff --git a/src/main/claude/claude-structured-command-dispatch.ts b/src/main/claude/claude-structured-command-dispatch.ts new file mode 100644 index 00000000000..3d03c18178c --- /dev/null +++ b/src/main/claude/claude-structured-command-dispatch.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'node:crypto' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Sent like any message, so the slash-command waiter settles it on its result. The translator + * makes the command's turn the open one before the send, and that same result ends it. */ +export async function dispatchClaudeCommand( + session: ClaudeSession, + command: StructuredAgentSessionCommandRun +): Promise { + const sentUuid = randomUUID() + session.translator?.beginCommand({ + ...command, + providerSessionId: session.providerSessionId, + sentUuid + }) + try { + const admission = await dispatchClaudeTurn(session, { + clientMessageId: command.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + ...(command.running.requestedAt === undefined + ? {} + : { requestedAt: command.running.requestedAt }), + sentUuid + }) + if (admission.state === 'rejected') { + session.translator?.forgetCommand(command.turnId) + } + return admission + } catch (error) { + session.translator?.forgetCommand(command.turnId) + throw error + } +} diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts deleted file mode 100644 index 70aabc5ae50..00000000000 --- a/src/main/claude/claude-structured-compaction.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' -import { compactClaudeSession, isClaudeCompactionContent } from './claude-structured-compaction' -import { sessionFor } from './claude-structured-dispatch-test-support' - -afterEach(() => { - vi.useRealTimers() -}) - -describe('Claude compaction transcript content', () => { - it('keeps generated summaries and command echoes out of the transcript only during explicit compaction', async () => { - const tracker = new StructuredSessionCompaction() - const event = { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'user', - session_id: 'provider', - uuid: 'summary', - message: { role: 'user', content: 'generated compaction summary' } - } - } - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => undefined) - expect(isClaudeCompactionContent(tracker, event)).toBe(true) - expect(isClaudeCompactionContent(tracker, { ...event, sessionId: 'other' })).toBe(false) - expect(isClaudeCompactionContent(tracker, { ...event, message: { type: 'result' } })).toBe( - false - ) - tracker.ended('orca-session') - await completion - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - }) - - it('fails a provably unwritten command without waiting for the completion deadline', async () => { - vi.useFakeTimers() - const session = sessionFor( - vi.fn().mockRejectedValue(claudeUnwrittenUserMessageError(new Error('input closed'))) - ) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(60_000), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - - await vi.advanceTimersByTimeAsync(1) - - await expect(pending).resolves.toEqual({ outcome: 'failed' }) - }) - - it('keeps waiting when the command write outcome is ambiguous', async () => { - vi.useFakeTimers() - const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(10), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - const rejection = expect(pending).rejects.toThrow('Compaction completion is unconfirmed.') - - await vi.advanceTimersByTimeAsync(10) - - await rejection - }) -}) diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts deleted file mode 100644 index 5b1f67e2e11..00000000000 --- a/src/main/claude/claude-structured-compaction.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { - StructuredSessionCompaction, - StructuredSessionCompactionResult -} from '../native-chat/agent-session-wire/structured-session-compaction' -import { dispatchClaudeTurn } from './claude-structured-dispatch' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -/** Compaction needs no ack deadline of its own: `compactions.run` keeps its own - * 180s completion window and settles on Claude's terminal `result` frame, so - * the dispatch here only has to report a refusal to send. */ -export function compactClaudeSession( - session: ClaudeSession, - compactions: StructuredSessionCompaction, - input: Parameters>[0] -): Promise { - return compactions.run( - input.sessionId, - session.providerSessionId, - async () => { - const result = await dispatchClaudeTurn(session, { - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } - }) - if (result.state === 'rejected') { - return { outcome: 'failed' as const } - } - return undefined - }, - input.onLateResult, - input.turnId - ) -} - -export function observeClaudeCompaction( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent, - translator: ClaudeSession['translator'] | undefined -): void { - if (!isClaudeCompactionContent(compactions, event)) { - translator?.handle(event) - } - if (event.type === 'message') { - compactions.claude(event.sessionId, event.message) - } - if (event.type === 'ended') { - compactions.ended(event.sessionId) - } -} - -export function isClaudeCompactionContent( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent -): boolean { - return ( - event.type === 'message' && - compactions.hasPending(event.sessionId) && - ['user', 'assistant', 'stream_event'].includes(String(event.message.type)) - ) -} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 7f197788b68..4ba08d3b642 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,6 +249,10 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), diff --git a/src/main/claude/claude-structured-conversation-stop.test.ts b/src/main/claude/claude-structured-conversation-stop.test.ts new file mode 100644 index 00000000000..90ca6569151 --- /dev/null +++ b/src/main/claude/claude-structured-conversation-stop.test.ts @@ -0,0 +1,101 @@ +// A Stop that names no turn, against the Claude adapter: the gap between writing a message and +// Claude echoing it back is exactly where no turn id exists yet, and the interrupt must still land. + +import { describe, expect, it } from 'vitest' +import { acquired, fakeClaude, USER_MESSAGE } from './claude-structured-session-test-support' + +function interrupts(claude: ReturnType): number { + return claude.connections[0]!.calls.filter((call) => call.subtype === 'interrupt').length +} + +async function written(claude: ReturnType) { + const adapter = await acquired(claude) + // No replay: the message is written and Claude has not opened its turn. + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + return adapter +} + +describe('Claude Stop that names no turn', () => { + it('interrupts a written message before its turn opens', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + }) + + it('interrupts that gap the same way when the Stop names a turn that is not live', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-ended', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(interrupts(claude)).toBe(1) + }) + + it('interrupts a turn Claude opened on its own echo', async () => { + const claude = fakeClaude({ replayUuid: 'turn-T' }) + const adapter = await written(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + }) + + it('does nothing for another fence, or with nothing in flight', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await acquired(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: false + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 6 })).resolves.toEqual({ + cancelled: false + }) + expect(interrupts(claude)).toBe(0) + }) + + it('ends a turn that opens while the interrupt is on its way once, and the next send lands', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + const connection = claude.connections[0]! + // The echo that opens the turn arrives after Orca wrote the interrupt, before Claude answers it. + claude.routes.interrupt = () => { + connection.handlers.onMessage?.({ ...connection.sent[0]!, uuid: 'turn-late' }) + return undefined + } + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + + delete claude.routes.interrupt + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-2', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(connection.sent).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 612f6391ced..c208cceccdb 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -79,7 +79,13 @@ function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchReject export async function dispatchClaudeTurn( session: ClaudeSession, - input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, + input: { + clientMessageId?: string + body: AgentJournalMessageItem + requestedAt?: number + /** The frame's uuid, for a caller that correlates the provider's answer to it. */ + sentUuid?: string + }, beforeDispatch?: () => Promise ): Promise { let content: unknown[] @@ -98,7 +104,7 @@ export async function dispatchClaudeTurn( // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) - const sentUuid = randomUUID() + const sentUuid = input.sentUuid ?? randomUUID() const arm = () => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts index 43c96ee8bea..1f84d3d7da2 100644 --- a/src/main/claude/claude-structured-inbound-control.test.ts +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { - buildClaudePermissionCallbacks, - CLAUDE_BLOCKING_CONTROL_CALLBACKS, - CLAUDE_CAN_USE_TOOL_SUBTYPE, - CLAUDE_REQUEST_USER_DIALOG_SUBTYPE -} from './claude-structured-inbound-control' +import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' type CanUseToolOptions = Parameters[2] @@ -210,20 +205,4 @@ describe('Claude permission callbacks', () => { ) ).resolves.toEqual({ behavior: 'cancelled' }) }) - - it('enumerates every blocking control request and wires a callback for each', () => { - // The stable surface of controls a turn can block on. Adding one here without wiring its - // callback below fails this test rather than silently leaving a control unhandled. - expect(new Set(Object.keys(CLAUDE_BLOCKING_CONTROL_CALLBACKS))).toEqual( - new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]) - ) - const callbacks = buildClaudePermissionCallbacks({ - sessionId: 'session-1', - prompts: new ClaudePromptRegistry(), - emit: vi.fn() - }) as unknown as Record - for (const callbackName of Object.values(CLAUDE_BLOCKING_CONTROL_CALLBACKS)) { - expect(typeof callbacks[callbackName], `${callbackName} must be wired`).toBe('function') - } - }) }) diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index b2bed91f1f4..fc2810e8a75 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -6,25 +6,6 @@ import { claudePermissionSubject } from './claude-permission-presentation' -export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool' -export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog' - -/** - * The blocking control requests Orca answers, each mapped to the SDK consumer callback that - * answers it. This is the stable surface a real turn can block on: `can_use_tool` through - * `canUseTool` and `request_user_dialog` through `onUserDialog`. Every other control-request - * subtype the SDK routes (elicitation, oauth/host token refresh, mcp_message, hook_callback) - * is either not surfaced to this consumer or fails closed inside the SDK; adding a new - * blocking control Orca must answer means adding its callback here, and the catalog test - * fails if a named callback is missing. - */ -export const CLAUDE_BLOCKING_CONTROL_CALLBACKS = { - [CLAUDE_CAN_USE_TOOL_SUBTYPE]: 'canUseTool', - [CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]: 'onUserDialog' -} as const - -export type ClaudeBlockingControlSubtype = keyof typeof CLAUDE_BLOCKING_CONTROL_CALLBACKS - export type ClaudePermissionCallbackDeps = { sessionId: string prompts: ClaudePromptRegistry diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 73dd35071ba..92301af106c 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -1,7 +1,8 @@ import type { AgentJournalApprovalItem, AgentJournalItemIdentity, - AgentJournalQuestionItem + AgentJournalQuestionItem, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' @@ -22,6 +23,7 @@ const ADMITTED = { accepted: true } as const type ClaudeJournalPrompt = { identity: AgentJournalItemIdentity body: AgentJournalApprovalItem | AgentJournalQuestionItem + turnScope: AgentJournalTurnScope } type ClaudeJournalPromptEntry = { @@ -54,6 +56,8 @@ export class ClaudeJournalPrompts { constructor( private readonly deps: { sink: StructuredAgentSessionEventSink + /** The turn that raised the prompt: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope bindPromptItemId?: (journalItemId: string, promptKey: string) => void questionItems?: (input: { sessionId: string @@ -75,13 +79,14 @@ export class ClaudeJournalPrompts { */ handle(event: Extract): void { const items: ClaudeJournalPrompt[] = [] + const turnScope = this.deps.turnScope() if (event.prompt.kind === 'question') { for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ sessionId: event.sessionId, prompt: event.prompt })) { - items.push(question) - this.deps.sink.appendItem(question.identity, question.body) + items.push({ ...question, turnScope }) + this.deps.sink.appendItem(question.identity, question.body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) } } else { @@ -90,8 +95,8 @@ export class ClaudeJournalPrompts { promptKey: event.prompt.promptKey }) const body = claudeApprovalItem(event.prompt) - items.push({ identity, body }) - this.deps.sink.appendItem(identity, body) + items.push({ identity, body, turnScope }) + this.deps.sink.appendItem(identity, body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) } this.deletePrompt(event.prompt.promptKey) @@ -104,10 +109,11 @@ export class ClaudeJournalPrompts { if (items.length === 0) { return ADMITTED } - const mutations = items.map(({ identity, body }) => ({ + const mutations = items.map(({ identity, body, turnScope }) => ({ kind: 'item' as const, identity, - body: cancelledPromptBody(body) + body: cancelledPromptBody(body), + turnScope })) let admission: StructuredAgentSessionSinkAdmission if (this.deps.sink.tryAppendLifecycleBatch) { @@ -129,9 +135,10 @@ export class ClaudeJournalPrompts { return ADMITTED } const body = cancelledPromptBody(item.body) + const options = { lifecycle: true, turnScope: item.turnScope } admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) - : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + ? this.deps.sink.tryAppendItem(item.identity, body, options) + : (this.deps.sink.appendItem(item.identity, body, options), ADMITTED) } else { return { accepted: false, reason: 'failed' } } diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 170f520e9ec..312eb6d05a2 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -8,7 +9,8 @@ import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget, - type StructuredAgentSessionLifecycleJournal + type StructuredAgentSessionLifecycleJournal, + type StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -154,6 +156,10 @@ function persistedTarget( visit(itemId, 0, body) } }, + // This double keeps no producer linkage, so every row reads as the session's own. + visitItemsWithLinkage: ((visit) => { + persisted.forEach((body, itemId) => visit(itemId, 0, body, {})) + }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, epoch: 'test' } as unknown as AgentSessionJournal @@ -201,7 +207,8 @@ describe('claude journal translation — background task rows', () => { deferred.bind(target) deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = systemFrame({ diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index d122d0af7a2..89df86122b8 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -529,7 +529,9 @@ describe('claude journal translation — which agent produced a row', () => { (entry) => orcaClientMessageId(entry.identity) === 'claude-tool:claude-session:toolu_bash' ) expect(row?.body).toMatchObject({ kind: 'tool-call', state: 'completed' }) - expect(row?.options).toEqual({}) + expect(row?.options).toEqual({ + turnScope: { kind: 'turn', turnItemId: expect.any(String) } + }) }) /** One streamed text block, as the SDK sends it: a message start, then deltas. diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ad76cd2792b..ee0f4970f4d 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -161,7 +161,7 @@ describe('Claude structured turn timing', () => { state: 'running', startedAt: 1_000, userItemId: USER_1_KEY, - options: { observedAt: 1_000 } + options: { observedAt: 1_000, turnScope: { kind: 'thread' } } } ]) }) @@ -194,7 +194,7 @@ describe('Claude structured turn timing', () => { startedAt: 1_000, completedAt: 4_500, userItemId: USER_1_KEY, - options: {} + options: { turnScope: { kind: 'thread' } } }) expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) }) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index d868b3816c4..1b03881e471 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -22,6 +22,7 @@ import { import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' function sinkState() { const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] @@ -253,7 +254,7 @@ describe('Claude structured journal translation', () => { it('journals a count-to-200 stream as one assistant item carrying the complete reply', async () => { const journal = await openAgentSessionJournal({ identity: JOURNAL_IDENTITY, - journalDir: journalRoot, + database: openTestJournalHostDatabase(journalRoot), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) @@ -312,7 +313,7 @@ describe('Claude structured journal translation', () => { it('restores a cancelled prompt as terminal history after reopening the journal', async () => { const journal = await openAgentSessionJournal({ identity: JOURNAL_IDENTITY, - journalDir: journalRoot, + database: openTestJournalHostDatabase(journalRoot), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) @@ -341,7 +342,7 @@ describe('Claude structured journal translation', () => { const reopened = await openAgentSessionJournal({ identity: JOURNAL_IDENTITY, - journalDir: journalRoot, + database: openTestJournalHostDatabase(journalRoot), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-2' }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 082b0024061..f49eed242d5 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -9,15 +9,14 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { claudeProviderFrameKind, - claudeResultFailure, - createClaudeProviderFrameFallback, - isSettledClaudeResultKind + createClaudeProviderFrameFallback } from './claude-structured-provider-fallback' import { taskFrameSentence } from './claude-background-task-frames' import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' import { ClaudeToolOriginRegistry } from './claude-tool-origin-registry' import { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections' import { ClaudeSubagentRoster } from './claude-subagent-roster' +import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' import { @@ -26,13 +25,14 @@ import { claudeStreamTurnSource, isRootClaudeFrame } from './claude-turn-opening' -import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' +import { claudeCommandCurrentTurn, observeClaudeCommandFrame } from './claude-command-turn' import { ClaudeContextFacts } from './claude-context-facts' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' +import { journalClaudeResult, type ClaudeResultJournalContext } from './claude-result-journaling' export type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -65,7 +65,9 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const prompts = new ClaudeJournalPrompts(deps) + // Every row joins the root turn open when it is written, whoever produced it. + const turnScope = () => turn.turnScope + const prompts = new ClaudeJournalPrompts({ ...deps, turnScope }) const streamedBlocks = createClaudeStreamedBlockRegistry() const turn = new ClaudeOpenTurn({ sink: deps.sink, @@ -73,16 +75,18 @@ export function createClaudeJournalTranslator( onOpen: () => context.markActivity() }) const context = new ClaudeContextFacts(turn, deps.sink) - const providerFallback = createClaudeProviderFrameFallback( - deps.sink, - deps.fallbackIdPrefix ?? 'acquisition' - ) + const fallbackId = deps.fallbackIdPrefix ?? 'acquisition' + const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() + const journaled = new ClaudeJournaledRoster(() => deps.sink.journalLinkage?.() ?? null) const subagents = new ClaudeSubagentRoster({ sink: deps.sink, currentGroupKey: () => turn.groupKey, + currentTurnScope: turnScope, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), + // A restarted provider continues the roster earlier runs journaled. + journaled, // A settled group can receive no further announcement, so a correction // still owed is never coming; the rows keep the stamp they already have. onIdentitiesFinal: () => corrections.abandon() @@ -90,6 +94,7 @@ export function createClaudeJournalTranslator( const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ ...subagents.linkage, + turnScope, rewrite: (identity, body, options) => { // The admission-returning path, so a correction the sink refuses under // backpressure stays owed instead of vanishing. Sinks without it accept @@ -106,10 +111,12 @@ export function createClaudeJournalTranslator( const backgroundTasks = new ClaudeBackgroundTaskRows({ sink: deps.sink, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), + rosteredByEarlierRun: (taskId) => journaled.groupOf(taskId) !== null, // A typed task row is provider output: journaling one must open a resumed // turn, or the session shows the row while reading idle. openOutputTurn: (frame, observedAt) => turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt), + turnScope, ...(deps.onBackgroundTaskJournalFailure ? { onPersistenceFailure: deps.onBackgroundTaskJournalFailure } : {}) @@ -119,7 +126,8 @@ export function createClaudeJournalTranslator( ...(deps.schedule ? { schedule: deps.schedule } : {}), producer: subagents.linkage, persist: (identity, text, options) => { - deps.sink.appendItem(identity, claudeStreamingMessageBody(text), options) + const body = claudeStreamingMessageBody(text) + deps.sink.appendItem(identity, body, { ...options, turnScope: turnScope() }) deps.sink.publish() } }) @@ -161,6 +169,8 @@ export function createClaudeJournalTranslator( turn } + const resultContext: ClaudeResultJournalContext = { ...messageContext, prompts, context } + const handleMessage = ( message: Record, startsTurn: boolean, @@ -200,6 +210,9 @@ export function createClaudeJournalTranslator( } } } + if (event.type === 'message' && observeClaudeCommandFrame(turn.command, event.message)) { + return + } if (event.type === 'message' && handleStream(event.message, event.observedAt ?? Date.now())) { return } @@ -218,41 +231,7 @@ export function createClaudeJournalTranslator( prompts.retryPendingCancellations() prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { - // Every turn this translator opens is root by construction, so a nested - // result settles the child that produced it and never the turn. The - // diagnostic below still runs: a child's failure is reportable even when - // it ends no turn. - const settlesTurn = isRootClaudeFrame(event.message) - if (settlesTurn) { - prompts.retryPendingCancellations() - turn.suppressReopenOnFailure(event.message.is_error === true) - // The turn is over however it ended, so a foreground child still - // reported as working will never be settled by an event. - subagents.settleTurn(turn.groupKey) - context.settle( - event.message, - claudeTurnEndForResult(event.message, event.observedAt ?? Date.now()) - ) - // The turn is over. A block still awaiting its final keeps the text the - // flush above journaled, but its live state goes: an interrupted turn - // would otherwise retain that text for the life of the session. - streamedBlocks.clear() - streamedText.settle() - } - const kind = claudeProviderFrameKind(event.message) - const failure = claudeResultFailure(event.message) - if (failure || !isSettledClaudeResultKind(kind)) { - providerFallback.append( - kind, - event.message, - failure?.text, - undefined, - undefined, - // A result that settles no turn is a CHILD's result: this - // translator only ever opens root turns. - settlesTurn ? undefined : corrections.stampFor(claudeFrameParentRef(event.message)) - ) - } + journalClaudeResult(resultContext, event.message, event.observedAt ?? Date.now()) } else if (event.type === 'message') { const backgroundTaskCovered = backgroundTasks.observe( event.message, @@ -296,6 +275,12 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + get commandTurnId() { + return turn.command ? turn.id : null + }, + beginCommand: (start) => turn.beginCommand(claudeCommandCurrentTurn(start)), + forgetCommand: (turnId) => turn.forgetCommand(turnId), + commandInterruptRequested: (turnId) => turn.commandInterruptRequested(turnId), get openTurnInLiveProviderCycle() { return turn.openedInLiveProviderCycle }, diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 53d6f63623d..31fd703088f 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -341,7 +342,10 @@ describe('Claude live prompt ownership', () => { }) it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { - const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + const prompts = new ClaudeJournalPrompts({ + sink: lifecycleRecorder().sink, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) for (let index = 0; index < 128; index += 1) { const promptKey = `resolved-${index}` @@ -673,7 +677,8 @@ describe('Claude live prompt ownership', () => { } ] : [] - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const prompt: ClaudePendingPrompt = { requestId: 'grouped-request', @@ -714,7 +719,8 @@ describe('Claude live prompt ownership', () => { lifecycleAttempts += 1 return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } } - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const registerCancellation = (index: number): void => { const promptKey = `permission-${index}` diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index e1c1aa371bf..78c63af8075 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -3,7 +3,6 @@ import { AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { answerClaudePrompt, @@ -16,11 +15,6 @@ import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudePendingPrompt } from './claude-prompt-registry' import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' -/** Conservative user-facing window: below the 30s control deadline, trading - * residual slow-pump risk for ensuring delivery bookkeeping cannot block Stop indefinitely. */ -export const CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS = 3_000 -const CLAUDE_DISPATCH_ADMISSION_POLL_MS = 50 - type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -56,49 +50,42 @@ function requireSession(sessions: Map, sessionId: string) return session } -function waitForClaudeDispatchAdmission( - admitted: () => boolean, - timeoutMs = CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS -): Promise { - return new Promise((resolve) => { - let settled = false - let deadline: ReturnType | null = null - let poll: ReturnType | null = null - const finish = (value: boolean): void => { - if (settled) { - return - } - settled = true - if (deadline) { - clearTimeout(deadline) - } - if (poll) { - clearInterval(poll) - } - resolve(value) - } - const check = (): void => { - if (admitted()) { - finish(true) - } - } - deadline = setTimeout(() => finish(false), timeoutMs) - poll = setInterval(check, CLAUDE_DISPATCH_ADMISSION_POLL_MS) - check() - deadline.unref?.() - poll.unref?.() - }) +/** The published journal's turn while it has one, else the in-memory turn (see the owner check). */ +function claudeLiveTurnId(session: ClaudeSession, request: CancelInput): string | null { + return request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null +} + +/** + * A Stop that names no turn, names the live one, or names one that ended before a written + * follow-up opened its own: the conversation asked to stop whatever this child has in flight. + * Claude's interrupt is session-scoped, so there is no turn identity to check — only that this is + * still the child the host judged, and that it has a turn open or a written message whose turn has + * not opened yet (the gap before its echo, which no client can name). + */ +function cancelClaudeConversation( + session: ClaudeSession, + sessions: Map, + request: CancelInput, + timeoutMs: number | undefined, + onDispatchSettledLate: ClaudeLateDispatchSettlement | undefined +): Promise<{ cancelled: boolean }> { + const acquisitionGeneration = session.acquisitionGeneration + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) + return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) } export async function cancelClaudeStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction timeoutMs?: number admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean onDispatchSettledLate?: ClaudeLateDispatchSettlement }): Promise<{ cancelled: boolean }> { - const { request, sessions, compactions, timeoutMs } = input + const { request, sessions, timeoutMs } = input const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration const prompt = request.prompt @@ -106,10 +93,33 @@ export async function cancelClaudeStructuredTurn(input: { if (!prompt && session.startup.state === 'pending') { return { cancelled: false } } + const requestedTurnId = request.turnId + const liveTurnId = claudeLiveTurnId(session, request) + // Naming the live turn asks for exactly what the conversation Stop interrupts, so a follow-up's + // pending handover is no reason to hold it; the queue sweep settles that follow-up. With nothing + // live, a written follow-up whose turn has not opened is one the naming client has not seen start. + if ( + !prompt && + (requestedTurnId === undefined || + (session.translator?.commandTurnId !== requestedTurnId && + (liveTurnId === requestedTurnId || + (liveTurnId === null && session.dispatchWaiters.length > 0)))) + ) { + return cancelClaudeConversation( + session, + sessions, + request, + timeoutMs, + input.onDispatchSettledLate + ) + } + if (requestedTurnId === undefined) { + return { cancelled: false } + } if (prompt && session.fence !== request.fence) { return { cancelled: false } } - const claim = prompt ? session.prompts.claimBound(prompt.itemId, request.turnId) : null + const claim = prompt ? session.prompts.claimBound(prompt.itemId, requestedTurnId) : null if (prompt && !claim) { return { cancelled: false } } @@ -124,8 +134,8 @@ export async function cancelClaudeStructuredTurn(input: { // the in-memory turn there keeps Stop from being gated on bookkeeping. No live turn either way // means nothing has published an identity this request can contradict. const ownsRequestedTurn = (): boolean => { - const liveTurnId = request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null - return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === request.turnId + const liveTurnId = claudeLiveTurnId(session, request) + return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === requestedTurnId } // The host supplies the durable latest submission; direct adapter callers fall back to // the current in-memory waiter so an unknown dispatch remains fenced without a latch. @@ -139,43 +149,34 @@ export async function cancelClaudeStructuredTurn(input: { (waiter) => waiter.dispatchSequence === session.dispatchSequence ) // Prompt cancellation has a separate callback-settlement contract, so only a provider with - // cancelQueued can release its uncertain queued send. Ordinary Stop gets a bounded escape below. + // cancelQueued can release its uncertain queued send. const dispatchAdmissionAllowsCancellation = (): boolean => dispatchAdmissionIsCurrent() || (Boolean(prompt) && supportsClaudeQueuedInterruptCancellation(session)) - const compactionOwnsTurn = (): boolean => compactions.ownsTurn(request.sessionId, request.turnId) - const currentDispatchHasRetiredWaiter = (): boolean => - session.retiredDispatchWaiters.some( - (waiter) => waiter.dispatchSequence === session.dispatchSequence - ) - let dispatchAdmissionExpired = false - if ( - !prompt && - !compactionOwnsTurn() && - !dispatchAdmissionAllowsCancellation() && - (request.dispatchStatus !== undefined || currentDispatchHasRetiredWaiter()) - ) { - dispatchAdmissionExpired = !(await waitForClaudeDispatchAdmission( - dispatchAdmissionAllowsCancellation - )) - } + const compactionOwnsTurn = (): boolean => + session.translator !== null && session.translator.commandTurnId === requestedTurnId const isCurrent = (): boolean => sessions.get(request.sessionId) === session && session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claim && prompt ? ownsRequestedTurn() && - session.prompts.ownsBoundClaim(claim, prompt.itemId, request.turnId) && - (dispatchAdmissionAllowsCancellation() || dispatchAdmissionExpired) - : compactionOwnsTurn() || - (ownsRequestedTurn() && - (dispatchAdmissionAllowsCancellation() || dispatchAdmissionExpired))) + session.prompts.ownsBoundClaim(claim, prompt.itemId, requestedTurnId) && + dispatchAdmissionAllowsCancellation() + : compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation())) let interruptConfirmed = false try { const result = await cancelClaudeTurn( session, timeoutMs, - isCurrent, + () => { + const current = isCurrent() + // Read with the result that ends it: a stopped command reports no compaction. + if (current && compactionOwnsTurn()) { + session.translator?.commandInterruptRequested(requestedTurnId) + } + return current + }, input.onDispatchSettledLate ) if (result.cancelled && claim && cancellationObserved) { diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts index e8b27114da4..a3a47c8bea8 100644 --- a/src/main/claude/claude-structured-provider-fallback.test.ts +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -10,6 +10,7 @@ import { openAgentSessionJournal } from '../native-chat/agent-session-journal/jo import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -75,7 +76,7 @@ describe('Claude provider fallback', () => { it('keeps provider-fallback rows distinct across acquisitions', async () => { const journal = await openAgentSessionJournal({ identity: IDENTITY, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index e107633173c..cd5441d7ec1 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { boundInlineText, @@ -15,7 +16,7 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' -import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, claudeApiRetryRowBody, @@ -109,7 +110,9 @@ export function isModeledClaudeContent(value: unknown): boolean { export function createClaudeProviderFrameFallback( sink: StructuredAgentSessionEventSink, - acquisitionId: string + acquisitionId: string, + /** The frame's turn — the open one once `beforeAppend` ran — or the conversation. */ + turnScope: () => AgentJournalTurnScope ): { /** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */ append: ( @@ -139,7 +142,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` } as const const body = claudeApiRetryRowBody(retrying) - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } @@ -162,7 +165,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` } as const const body = bounded ? { ...translated.body, text: bounded } : translated.body - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } diff --git a/src/main/claude/claude-structured-queued-stop.test.ts b/src/main/claude/claude-structured-queued-stop.test.ts index 2dbc99bb82b..6f2f19e5fab 100644 --- a/src/main/claude/claude-structured-queued-stop.test.ts +++ b/src/main/claude/claude-structured-queued-stop.test.ts @@ -8,7 +8,6 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' -import { CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS } from './claude-structured-prompt-ownership' import { PROVIDER_SESSION_ID, USER_MESSAGE, @@ -82,50 +81,90 @@ async function acquired( return { adapter, bodies, connection: claude.connections[0]! } } -function runningTurnId(bodies: Map): string { +function openTurnId(bodies: Map): string | null { for (const body of bodies.values()) { const turn = readAgentJournalTurn(body) if (turn?.state === 'running') { return turn.turnId } } - throw new Error('expected a running turn') + return null } -/** Sends A and opens its turn, queues B behind it, then Stops A as the host does. */ -async function stopWithQueuedFollowUp( - cli: ReturnType, - openTurn: (connection: FakeConnection) => void -): Promise<{ interrupt: unknown; settlements: Settlement[] }> { - const settlements: Settlement[] = [] - const { adapter, bodies, connection } = await acquired(cli.claude, settlements) - const send = (clientMessageId: string) => - adapter.dispatch({ sessionId: 'session-1', clientMessageId, body: USER_MESSAGE, fence: 7 }) - await send('client-a') +function runningTurnId(bodies: Map): string { + const turnId = openTurnId(bodies) + if (turnId === null) { + throw new Error('expected a running turn') + } + return turnId +} + +function completeTurn(connection: FakeConnection, uuid: string): void { + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid, + session_id: PROVIDER_SESSION_ID, + is_error: false, + terminal_reason: 'completed', + duration_ms: 12 + }) +} + +/** Writes a send and opens its turn with the echo, which adopts the client uuid. */ +async function sendAndOpen( + adapter: ReturnType, + connection: FakeConnection, + clientMessageId: string, + openTurn: (connection: FakeConnection) => void = () => {} +): Promise { + await send(adapter, clientMessageId) openTurn(connection) - // Claude adopts the client uuid for the echo that opens the turn. connection.handlers.onMessage?.({ ...connection.sent.at(-1)!, uuid: connection.sent.at(-1)!.uuid }) - await send('client-b') - cli.queue(String(connection.sent.at(-1)!.uuid)) +} +function send(adapter: ReturnType, clientMessageId: string) { + return adapter.dispatch({ sessionId: 'session-1', clientMessageId, body: USER_MESSAGE, fence: 7 }) +} + +/** Stops `turnId` as the phone does, naming it, while the journal still reads the queued send as + * pending, and reads the outcome without moving the clock: that handover must hold no Stop. */ +async function namedStop(adapter: ReturnType, turnId: string): Promise { vi.useFakeTimers() try { - const stopped = adapter.cancelTurn({ - sessionId: 'session-1', - turnId: runningTurnId(bodies), - fence: 7, - // The host reads B's handover from the journal, and it is still pending. - dispatchStatus: { state: 'pending', recovered: false } - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS) - await expect(stopped).resolves.toEqual({ cancelled: true }) + let outcome: unknown = 'still waiting' + void adapter + .cancelTurn({ + sessionId: 'session-1', + turnId, + fence: 7, + dispatchStatus: { state: 'pending', recovered: false } + }) + .then((result) => (outcome = result)) + await vi.advanceTimersByTimeAsync(0) + return outcome } finally { vi.useRealTimers() } +} + +/** Sends A and opens its turn, queues B behind it, then Stops A by name as the phone does. */ +async function stopWithQueuedFollowUp( + cli: ReturnType, + openTurn: (connection: FakeConnection) => void +): Promise<{ stopped: unknown; interrupt: unknown; settlements: Settlement[] }> { + const settlements: Settlement[] = [] + const { adapter, bodies, connection } = await acquired(cli.claude, settlements) + await sendAndOpen(adapter, connection, 'client-a', openTurn) + await send(adapter, 'client-b') + cli.queue(String(connection.sent.at(-1)!.uuid)) + + const stopped = await namedStop(adapter, runningTurnId(bodies)) return { + stopped, interrupt: connection.calls.find((call) => call.subtype === 'interrupt')?.params, settlements: settlements.filter((settlement) => settlement.clientMessageId === 'client-b') } @@ -152,7 +191,11 @@ describe('Stop with a follow-up Claude queued behind the running turn', () => { (connection) => connection.handlers.onMessage?.(turnInit(CAPABILITIES)) ) - expect(byInit).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + expect(byInit).toEqual({ + stopped: { cancelled: true }, + interrupt: { cancelQueued: true }, + settlements: WITHDRAWN + }) expect(bySessionStart).toEqual(byInit) }) @@ -171,7 +214,11 @@ describe('Stop with a follow-up Claude queued behind the running turn', () => { } ) - expect(result).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + expect(result).toEqual({ + stopped: { cancelled: true }, + interrupt: { cancelQueued: true }, + settlements: WITHDRAWN + }) }) it('keeps what a turn advertised when the start is read after it', async () => { @@ -191,7 +238,11 @@ describe('Stop with a follow-up Claude queued behind the running turn', () => { const result = await stopWithQueuedFollowUp(cli, () => {}) - expect(result).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + expect(result).toEqual({ + stopped: { cancelled: true }, + interrupt: { cancelQueued: true }, + settlements: WITHDRAWN + }) }) it('settles a follow-up withdrawn one at a time on a CLI without cancel_queued', async () => { @@ -200,6 +251,48 @@ describe('Stop with a follow-up Claude queued behind the running turn', () => { () => {} ) - expect(result).toEqual({ interrupt: {}, settlements: WITHDRAWN }) + expect(result).toEqual({ stopped: { cancelled: true }, interrupt: {}, settlements: WITHDRAWN }) + }) + + it('still refuses a Stop that names an older turn than the one running', async () => { + const settlements: Settlement[] = [] + const cli = claudeCli({ initProof: 'init', capabilities: CAPABILITIES }) + const { adapter, bodies, connection } = await acquired(cli.claude, settlements) + await sendAndOpen(adapter, connection, 'client-a') + const olderTurnId = runningTurnId(bodies) + completeTurn(connection, 'result-a') + await sendAndOpen(adapter, connection, 'client-b') + const liveTurnId = runningTurnId(bodies) + await send(adapter, 'client-c') + cli.queue(String(connection.sent.at(-1)!.uuid)) + + expect(liveTurnId).not.toBe(olderTurnId) + await expect(namedStop(adapter, olderTurnId)).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(runningTurnId(bodies)).toBe(liveTurnId) + expect(settlements.filter((settlement) => settlement.clientMessageId === 'client-c')).toEqual( + [] + ) + }) + + it('withdraws a follow-up whose turn has not opened when the turn the Stop names already ended', async () => { + const settlements: Settlement[] = [] + const cli = claudeCli({ initProof: 'init', capabilities: CAPABILITIES }) + const { adapter, bodies, connection } = await acquired(cli.claude, settlements) + await sendAndOpen(adapter, connection, 'client-a') + const shownTurnId = runningTurnId(bodies) + await send(adapter, 'client-b') + cli.queue(String(connection.sent.at(-1)!.uuid)) + // The phone's copy still shows the first turn, which ends before its Stop lands. + completeTurn(connection, 'result-a') + expect(openTurnId(bodies)).toBeNull() + + await expect(namedStop(adapter, shownTurnId)).resolves.toEqual({ cancelled: true }) + expect(connection.calls.find((call) => call.subtype === 'interrupt')?.params).toEqual({ + cancelQueued: true + }) + expect(settlements.filter((settlement) => settlement.clientMessageId === 'client-b')).toEqual( + WITHDRAWN + ) }) }) diff --git a/src/main/claude/claude-structured-rate-limit-retry.test.ts b/src/main/claude/claude-structured-rate-limit-retry.test.ts new file mode 100644 index 00000000000..d7b0d5277fc --- /dev/null +++ b/src/main/claude/claude-structured-rate-limit-retry.test.ts @@ -0,0 +1,50 @@ +// Claude retrying a rate-limited request: the frames below follow Claude Code 2.1.280 against an +// HTTP 429 stub, captured with `--replay-user-messages --include-partial-messages`. The CLI writes +// only `api_retry` frames, and echoes the message only once a request gets through or is +// interrupted, so no turn opens: the send itself is all that says the session is working. + +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function apiRetry(attempt: number, observedAt: number) { + return { + type: 'message' as const, + sessionId: 'orca-session', + observedAt, + message: { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 1_000 * 2 ** (attempt - 1), + error_status: 429, + error: 'rate_limit', + uuid: `retry-${attempt}`, + session_id: 'claude-session' + } + } +} + +describe('Claude retrying a rate-limited request', () => { + it('opens no turn while it retries', () => { + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink }) + + for (let attempt = 1; attempt <= 6; attempt += 1) { + translator.handle(apiRetry(attempt, 1_000 * attempt)) + } + + expect(appended.filter(({ body }) => readAgentJournalTurn(body) !== null)).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 20670147ca0..677d2de4cb9 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -10,6 +10,7 @@ import { realClaudeAuthenticated, realClaudeAuthStatus, realClaudeAvailable, + realClaudeCliGate, realClaudeCommand } from './claude-real-cli-availability-test-support' import { @@ -19,6 +20,7 @@ import { import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' const command = realClaudeCommand +const suiteTitle = `Claude structured real CLI handshake${realClaudeCliGate.skipReason ? ` (skipped: ${realClaudeCliGate.skipReason})` : ''}` function realAdapter( providerSessionId: string, @@ -79,7 +81,7 @@ async function waitForResolvedTranscript( } } -describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => { +describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { it.skipIf(!realClaudeAuthenticated)( 'proves a pre-minted session before the first user message', async () => { @@ -279,6 +281,80 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () 90_000 ) + // The window a Stop naming no turn exists for: Orca has written the message, and Claude has not + // started its reply, so no turn id exists. Only the live binary can say the interrupt lands there. + it.skipIf(!realClaudeAuthenticated)( + 'stops a message interrupted before its reply starts, and takes the next one', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + const frames = (from: number) => + events.slice(from).flatMap((event) => (event.type === 'message' ? [event.message] : [])) + const replyStarted = (from: number) => + frames(from).some( + (frame) => + frame.type === 'stream_event' && + typeof frame.event === 'object' && + frame.event !== null && + 'type' in frame.event && + frame.event.type === 'message_start' + ) + const result = async (from: number) => { + const deadline = Date.now() + 60_000 + for (;;) { + const found = frames(from).find((frame) => frame.type === 'result') + if (found || Date.now() >= deadline) { + return found + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + const send = (clientMessageId: string, text: string) => + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + fence: 1 + }) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-stop' + }) + const first = events.length + await expect( + send('real-cli-stop-1', 'Count from 1 to 300, one number per line, and nothing else.') + ).resolves.toEqual({ state: 'admitted' }) + const startedBeforeStop = replyStarted(first) + + await expect( + adapter.cancelTurn({ sessionId: 'real-cli-handshake', fence: 1 }) + ).resolves.toEqual({ + cancelled: true + }) + const stopped = await result(first) + + expect(startedBeforeStop).toBe(false) + // Claude 2.1.280 echoes the message, then ends that turn as interrupted without replying. + expect(stopped).toMatchObject({ subtype: 'error_during_execution' }) + expect(replyStarted(first)).toBe(false) + + const second = events.length + await expect(send('real-cli-stop-2', 'Reply with the single word ok.')).resolves.toEqual({ + state: 'admitted' + }) + await expect(result(second)).resolves.toMatchObject({ subtype: 'success' }) + } finally { + await adapter.closeAll() + } + }, + 150_000 + ) + // Orca installs a SessionStart hook, so its frame proves most real starts before the turn's // system/init, the only frame that says this CLI can cancel what it queued. it.skipIf(!realClaudeAuthenticated)( diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b5a7a5d3061..b4adbfccfa5 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -8,6 +8,7 @@ import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { readClaudeCapabilities, readClaudeFrameString, @@ -117,10 +118,17 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } + // The CLI idles only once its queue drains, so it holds none of this session's sends. + if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { + deps.onSessionIdle?.({ sessionId }) + } } + // Settled after the turn this echo opens is emitted: a send read as answered before its turn + // lands reads as nothing running, and Stop and Working blink off in between. + const settlements: (() => void)[] = [] const turnOrigin = liveSession ? resolveClaudeReplayTurn(liveSession, message, (settlement) => - deps.onDispatchSettledLate?.({ sessionId, ...settlement }) + settlements.push(() => deps.onDispatchSettledLate?.({ sessionId, ...settlement })) ) : null const startsTurn = turnOrigin !== null @@ -138,6 +146,9 @@ export async function acquireClaudeSession({ ...observedAt }) ) + for (const settle of settlements) { + settle() + } } const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, 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 d410861464a..3d0512d8b7c 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -144,41 +144,49 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { ) }) - it('does not cancel an acknowledged turn after a later dispatch is still unacknowledged', async () => { - const claude = fakeClaude({ replayUuids: ['turn-T', null] }) - const settled = vi.fn() - const adapter = await acquired(claude, {}, [], settled) + // Claude's interrupt is session-scoped, so naming the running turn asks for what a Stop naming + // no turn does; the later send's missing echo holds neither. + it.each([ + ['names the running turn', { turnId: 'turn-T' }], + ['names no turn', {}] + ])( + 'stops an acknowledged turn when the Stop %s, while a later send is unacknowledged', + async (_label, target) => { + const claude = fakeClaude({ replayUuids: ['turn-T', null] }) + const settled = vi.fn() + const adapter = await acquired(claude, {}, [], settled) - await expect( - adapter.dispatch({ + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ sessionId: 'session-1', clientMessageId: 'client-T', - body: USER_MESSAGE, - fence: 7 + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'turn-T' } }) - ).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-T', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'turn-T' } - }) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-U', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toHaveLength(2) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toHaveLength(2) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( - 0 - ) - }) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', fence: 7, ...target }) + ).resolves.toEqual({ cancelled: true }) + expect( + claude.connections[0].calls.filter((call) => call.subtype === 'interrupt') + ).toHaveLength(1) + } + ) it('classifies provider-declined options without treating timeouts as settled', async () => { const claude = fakeClaude({ diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 3e62f1fd267..09d97ce191a 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,5 +1,5 @@ import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' -import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' +import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, @@ -7,7 +7,6 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' @@ -55,7 +54,6 @@ function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTask } export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() private readonly exits = new Map() @@ -154,7 +152,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda if (event.type === 'message' && session?.commands.observe(event.message)) { session.events?.publish() } - observeClaudeCompaction(this.compactions, event, session?.translator) + session?.translator?.handle(event) this.deps.onEvent?.(event) this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) } @@ -185,13 +183,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) compact: NonNullable = (input) => - compactClaudeSession(this.session(input.sessionId), this.compactions, input) + dispatchClaudeCommand(this.session(input.sessionId), input.command) cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => cancelClaudeStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, admitPromptCancellation: (session, promptKey) => admitClaudePromptCancellation(session, promptKey), onDispatchSettledLate: (settlement) => diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 0389374384d..ed87ff392de 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -12,7 +12,7 @@ import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 9a4d3611435..9e3ee0289cf 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -4,7 +4,7 @@ import { } from './claude-context-usage' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeInitProof } from './claude-structured-session-startup' import type { ClaudeAcquisitionAttempt, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index f20bc0c7907..c685c2cd2ae 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -1,7 +1,7 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 1084c2c8626..d89bd1feb09 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -1,8 +1,10 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget, type StructuredAgentSessionEventSink, + type StructuredAgentSessionLinkageJournal, type StructuredAgentSessionReadingControl } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' @@ -59,6 +61,10 @@ function persistedTarget( visit(itemId, 0, body) } }, + // This double keeps no producer linkage, so every row reads as the session's own. + visitItemsWithLinkage: ((visit) => { + persisted.forEach((body, itemId) => visit(itemId, 0, body, {})) + }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, epoch: 'test' } as unknown as AgentSessionJournal @@ -236,7 +242,8 @@ describe('Claude structured reading control', () => { const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 5e8d8312b9d..c2a3b0fcc25 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -11,7 +11,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import { randomUUID } from 'node:crypto' @@ -92,6 +92,8 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void + /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + onSessionIdle?: (input: { sessionId: string }) => void /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void openConnection?: typeof openClaudeStreamJsonConnection @@ -132,6 +134,8 @@ export type ClaudeDispatchWaiter = { settledUuid?: string /** The write failed or the child died, but a replay may still name it. */ retired?: boolean + /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ + commandLifecycle?: 'queued' | 'started' /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index ab44350606e..5341d53af62 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -3,19 +3,65 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { RosterGroup } from './claude-subagent-roster-state' +type GroupRowAttempts = (entryId: string) => number + +/** A group row an earlier run journaled: its children and the turn it was written beside. */ +export type JournaledClaudeSubagentGroup = { + entries: readonly NativeChatSubagentEntry[] + turnScope: AgentJournalTurnScope +} + /** Durable journal identity for the group's row — stable across revisions and * across a restart, so replay finds the same row instead of appending a new one. */ export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` } } +/** + * A group row an earlier provider run journaled, as this run's roster state. + * + * Inheriting writes nothing: the row is revised only when this run's own events + * change the group, and then it keeps every child the earlier run listed. Each + * child outlived the run that spawned it, so none is tied to this run's turns. + */ +export function inheritedClaudeSubagentGroup( + groupId: string, + { entries, turnScope }: JournaledClaudeSubagentGroup, + attemptOf: GroupRowAttempts +): RosterGroup { + const group: RosterGroup = { + groupId, + identity: claudeSubagentGroupIdentity(groupId), + // The row keeps the turn it was created beside; a later run's writes never move it. + turnScope, + entries: new Map(), + admittedEntries: entries.length, + claimedLabels: new Set(), + lastSerialized: JSON.stringify(claudeSubagentGroupBody(groupId, entries)) + } + for (const entry of entries) { + group.claimedLabels.add(entry.label) + group.entries.set(entry.id, { + entry: { ...entry }, + backgrounded: true, + toolUseId: null, + invocationIds: new Set(), + labelBase: entry.label, + attempt: attemptOf(entry.id), + invokedInEarlierRun: true + }) + } + return group +} + /** The roster row: the structured block plus the plain sentence an older client * renders in its place. A message whose only block is the new variant would * reach such a client with nothing it can draw. */ @@ -46,7 +92,7 @@ export function writeClaudeSubagentGroupRow( group: RosterGroup ): void { const agents = [...group.entries.values()].map((tracked) => tracked.entry) - const options = { coalescingKey: `claude-subagents:${group.groupId}` } + const options = { coalescingKey: `claude-subagents:${group.groupId}`, turnScope: group.turnScope } if (agents.length === 0) { // The row's last child turned out not to be a subagent. An empty roster is // not a roster of nothing, so the row goes rather than reading "Ran 0". diff --git a/src/main/claude/claude-subagent-id-aliases.test.ts b/src/main/claude/claude-subagent-id-aliases.test.ts index bffb549d173..8dfc9a3bc0b 100644 --- a/src/main/claude/claude-subagent-id-aliases.test.ts +++ b/src/main/claude/claude-subagent-id-aliases.test.ts @@ -58,3 +58,17 @@ describe('ClaudeSubagentIds', () => { expect(ids.isExcluded('task-1')).toBe(false) }) }) + +describe('ClaudeSubagentIds with an earlier run journaled', () => { + it("prefers what this run was told, and falls back to the earlier run's alias", () => { + const ids = new ClaudeSubagentIds((toolUseId) => + toolUseId === 'toolu_spawn' || toolUseId === 'toolu_both' ? 'task-journaled' : null + ) + ids.alias('toolu_both', 'task-live') + expect(ids.canonical('toolu_both')).toBe('task-live') + expect(ids.canonical('toolu_spawn')).toBe('task-journaled') + expect(ids.isAnnounced('toolu_spawn')).toBe(true) + expect(ids.isAnnounced('toolu_unknown')).toBe(false) + expect(ids.canonical('toolu_unknown')).toBe('toolu_unknown') + }) +}) diff --git a/src/main/claude/claude-subagent-id-aliases.ts b/src/main/claude/claude-subagent-id-aliases.ts index 97c162193be..993b13d7c85 100644 --- a/src/main/claude/claude-subagent-id-aliases.ts +++ b/src/main/claude/claude-subagent-id-aliases.ts @@ -8,6 +8,11 @@ // child traffic carries no task metadata at all, so the one announcement that // said "this is a backgrounded shell, not an agent" has to be remembered or a // later frame re-admits it. +// +// An alias outlives the process that learned it. A child resumed after its +// session's provider restarted still parents its frames to the ORIGINAL spawn +// call, while the announcement this process sees names only the resuming call — +// so the spawn call's alias is recalled from what the earlier run journaled. import { isBoundedClaudeTaskId } from './claude-background-task-tracker' @@ -19,16 +24,20 @@ export class ClaudeSubagentIds { private readonly canonicalByToolUse = new Map() private readonly excluded = new Set() + /** `journaled` answers for aliases an earlier run of this session learned. */ + constructor(private readonly journaled?: (toolUseId: string) => string | null) {} + /** The task id a tool id stands for, or the id itself when nothing aliases it. */ canonical(id: string): string { - return this.canonicalByToolUse.get(id) ?? id + return this.canonicalByToolUse.get(id) ?? this.journaled?.(id) ?? id } - /** Whether an announcement has named this tool id. Distinct from `canonical` - * returning the id unchanged, which is also what an unknown id gets: only - * this says the identity behind the id is settled rather than provisional. */ + /** Whether an announcement has named this tool id, in this run or an earlier + * one. Distinct from `canonical` returning the id unchanged, which is also + * what an unknown id gets: only this says the identity behind the id is + * settled rather than provisional. */ isAnnounced(toolUseId: string): boolean { - return this.canonicalByToolUse.has(toolUseId) + return this.canonicalByToolUse.has(toolUseId) || (this.journaled?.(toolUseId) ?? null) !== null } alias(toolUseId: string, taskId: string): void { diff --git a/src/main/claude/claude-subagent-journaled-roster.test.ts b/src/main/claude/claude-subagent-journaled-roster.test.ts new file mode 100644 index 00000000000..d99bf28b2ad --- /dev/null +++ b/src/main/claude/claude-subagent-journaled-roster.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' +import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster' + +type Row = { + itemId: string + sequence: number + body: AgentJournalItemBody + linkage: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } +} + +const prose: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'working on it' }] +} + +const agentRow = (sequence: number, linkage: AgentJournalProducerLinkage): Row => ({ + itemId: `claude:claude-session:row-${sequence}`, + sequence, + body: prose, + linkage +}) + +const groupRow = ( + sequence: number, + groupId: string, + agents: NativeChatSubagentEntry[], + itemId = agentJournalItemKey(claudeSubagentGroupIdentity(groupId)), + turnScope?: AgentJournalTurnScope +): Row => ({ + itemId, + sequence, + body: claudeSubagentGroupBody(groupId, agents), + linkage: turnScope ? { turnScope } : {} +}) + +const entry = (id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry => ({ + id, + label: `Child ${id}`, + state, + startedAt: 1 +}) + +function linkageJournal( + epoch: string, + rows: () => Row[] +): { + epoch: string + visitItemsWithLinkage: StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'] +} { + return { + epoch, + visitItemsWithLinkage: (visit) => { + for (const row of rows()) { + visit(row.itemId, row.sequence, row.body, row.linkage) + } + } + } +} + +describe('ClaudeJournaledRoster', () => { + it('recalls only an agent row that resolved its reference to another id', () => { + const rows = [ + agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), + // Stamped with its own reference: never resolved, so it names no alias. + agentRow(2, { agentId: 'toolu_raw', providerParentRef: 'toolu_raw', producerKind: 'agent' }), + // A backgrounded shell is not a subagent and never outlives its process. + agentRow(3, { + agentId: 'shell-1', + providerParentRef: 'toolu_shell', + producerKind: 'background' + }) + ] + const journal = linkageJournal('epoch-1', () => rows) + const journaled = new ClaudeJournaledRoster(() => journal) + expect(journaled.canonical('toolu_a')).toBe('task-a') + expect(journaled.canonical('toolu_raw')).toBeNull() + expect(journaled.canonical('toolu_shell')).toBeNull() + }) + + it('reads the latest run any row of a child records', () => { + const rows = [ + agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), + agentRow(2, { + agentId: 'task-a', + providerParentRef: 'toolu_a', + producerKind: 'agent', + attempt: 3 + }), + agentRow(3, { + agentId: 'task-a', + providerParentRef: 'toolu_a', + producerKind: 'agent', + attempt: 2 + }) + ] + const journal = linkageJournal('epoch-1', () => rows) + const journaled = new ClaudeJournaledRoster(() => journal) + expect(journaled.attempt('task-a')).toBe(3) + expect(journaled.attempt('task-unknown')).toBe(1) + }) + + it('hands an earlier run’s group over once, and places a child in the newest row listing it', () => { + const turnA: AgentJournalTurnScope = { + kind: 'turn', + turnItemId: 'claude:claude-session:turn-a' + } + const rows = [ + groupRow( + 10, + 'turn-a', + [entry('task-a', 'failed'), entry('task-b', 'failed')], + undefined, + turnA + ), + groupRow(20, 'turn-b', [entry('task-a', 'working')]), + // Another lane's group block under a key this roster never writes. + groupRow(30, 'turn-c', [entry('task-c', 'working')], 'orca:elsewhere') + ] + const journal = linkageJournal('epoch-1', () => rows) + const journaled = new ClaudeJournaledRoster(() => journal) + expect(journaled.groupOf('task-a')).toBe('turn-b') + expect(journaled.groupOf('task-b')).toBe('turn-a') + expect(journaled.groupOf('task-c')).toBeNull() + const claimed = journaled.claimGroup('turn-a') + expect(claimed?.entries.map((agent) => agent.id)).toEqual(['task-a', 'task-b']) + // The row keeps the turn it was created beside. + expect(claimed?.turnScope).toEqual(turnA) + // After that the roster's own copy is the newer one. + expect(journaled.claimGroup('turn-a')).toBeNull() + }) + + it('reads nothing before the journal is bound, and does not keep that answer', () => { + let bound: StructuredAgentSessionLinkageJournal | null = null + const journaled = new ClaudeJournaledRoster(() => bound) + expect(journaled.canonical('toolu_a')).toBeNull() + expect(journaled.claimGroup('turn-a')).toBeNull() + bound = linkageJournal('epoch-1', () => [ + agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), + groupRow(2, 'turn-a', [entry('task-a', 'failed')]) + ]) + expect(journaled.canonical('toolu_a')).toBe('task-a') + expect(journaled.claimGroup('turn-a')?.entries).toHaveLength(1) + }) + + it('re-reads the same journal once its epoch is replaced', () => { + let rows = [ + agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }) + ] + const journal = linkageJournal('epoch-1', () => rows) + const journaled = new ClaudeJournaledRoster(() => journal) + expect(journaled.canonical('toolu_a')).toBe('task-a') + rows = [agentRow(1, { agentId: 'task-b', providerParentRef: 'toolu_b', producerKind: 'agent' })] + journal.epoch = 'epoch-2' + expect(journaled.canonical('toolu_a')).toBeNull() + expect(journaled.canonical('toolu_b')).toBe('task-b') + }) +}) diff --git a/src/main/claude/claude-subagent-journaled-roster.ts b/src/main/claude/claude-subagent-journaled-roster.ts new file mode 100644 index 00000000000..8c84e6f2e5c --- /dev/null +++ b/src/main/claude/claude-subagent-journaled-roster.ts @@ -0,0 +1,135 @@ +// What earlier runs of this session left of the Claude subagent roster, re-derived +// from the rows they journaled. +// +// The roster lives in one provider process, but everything it writes outlives that +// process: a group row keeps a restart-stable identity, and every agent row names its +// canonical id beside the call its frames arrived under. A run that started from +// nothing re-rostered a resumed child in a second row, restarted its attempts, lost +// the alias its resumed frames still carry, and rewrote the one group row no turn +// owns from empty. So a run reads what earlier runs left instead: derived, never +// stored, so it cannot disagree with the rows it came from. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalProducerLinkage +} from '../../shared/agent-session-journal-types' +import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' +import { + claudeSubagentGroupIdentity, + type JournaledClaudeSubagentGroup +} from './claude-subagent-group-row' + +/** What the roster asks of earlier runs. */ +export type ClaudeJournaledRosterSource = { + /** The task id an earlier run resolved this spawn call to. */ + canonical: (toolUseId: string) => string | null + /** The group row an earlier run last listed this child in. */ + groupOf: (entryId: string) => string | null + /** An earlier run's row for one group, handed over once: after that the + * roster's own copy is the newer one. */ + claimGroup: (groupId: string) => JournaledClaudeSubagentGroup | null + /** The latest run of this child any row records; 1 when none says more. */ + attempt: (agentId: string) => number +} + +type JournaledRosterReading = { + canonicalByToolUse: Map + attemptByAgent: Map + rowsByGroup: Map + groupByEntry: Map +} + +/** + * Read once per bound journal epoch. Before the journal is bound nothing is read and + * nothing is kept, so the first read after bind still sees every row. + */ +export class ClaudeJournaledRoster implements ClaudeJournaledRosterSource { + private journal: StructuredAgentSessionLinkageJournal | null = null + private epoch: string | null = null + private reading: JournaledRosterReading | null = null + + constructor(private readonly bound: () => StructuredAgentSessionLinkageJournal | null) {} + + canonical = (toolUseId: string): string | null => + this.current()?.canonicalByToolUse.get(toolUseId) ?? null + + groupOf = (entryId: string): string | null => this.current()?.groupByEntry.get(entryId) ?? null + + claimGroup = (groupId: string): JournaledClaudeSubagentGroup | null => { + const reading = this.current() + const row = reading?.rowsByGroup.get(groupId) ?? null + reading?.rowsByGroup.delete(groupId) + return row + } + + attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 1 + + private current(): JournaledRosterReading | null { + const journal = this.bound() + if (!journal) { + return null + } + if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) { + this.journal = journal + this.epoch = journal.epoch + this.reading = readJournaledRoster(journal) + } + return this.reading + } +} + +function readJournaledRoster( + journal: StructuredAgentSessionLinkageJournal +): JournaledRosterReading { + const reading: JournaledRosterReading = { + canonicalByToolUse: new Map(), + attemptByAgent: new Map(), + rowsByGroup: new Map(), + groupByEntry: new Map() + } + // A child two rows list (only an older build wrote that) is the later-created row's: a turn's + // row is created with its turn, so that is where it last ran. + const listedAt = new Map() + journal.visitItemsWithLinkage((itemId, sequence, body, attribution) => { + readAgentRow(reading, attribution) + const group = body.kind === 'message' ? body.blocks.find(isSubagentGroupBlock) : undefined + if (!group || itemId !== agentJournalItemKey(claudeSubagentGroupIdentity(group.groupId))) { + return + } + reading.rowsByGroup.set(group.groupId, { + entries: group.agents, + turnScope: attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + for (const entry of group.agents) { + if ((listedAt.get(entry.id) ?? -1) < sequence) { + listedAt.set(entry.id, sequence) + reading.groupByEntry.set(entry.id, group.groupId) + } + } + }) + return reading +} + +function readAgentRow( + reading: JournaledRosterReading, + { agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage +): void { + if (producerKind !== 'agent' || agentId === undefined) { + return + } + if (attempt !== undefined && attempt > (reading.attemptByAgent.get(agentId) ?? 1)) { + reading.attemptByAgent.set(agentId, attempt) + } + // A row stamped with its own reference was never resolved, so it names no alias. + if ( + providerParentRef !== undefined && + agentId !== providerParentRef && + isBoundedClaudeTaskId(agentId) && + isBoundedClaudeTaskId(providerParentRef) + ) { + reading.canonicalByToolUse.set(providerParentRef, agentId) + } +} diff --git a/src/main/claude/claude-subagent-linkage.ts b/src/main/claude/claude-subagent-linkage.ts index 876da765137..66f159c1034 100644 --- a/src/main/claude/claude-subagent-linkage.ts +++ b/src/main/claude/claude-subagent-linkage.ts @@ -96,7 +96,9 @@ export class ClaudeSubagentLinkage implements ClaudeSubagentLinkageSource { const excluded = this.deps.ids.isExcluded(parentToolUseId, canonical) // An announcement named this spawn call, so the task id behind it is // settled — including the case where the two ids are the same string, which - // comparing them could not tell from never having been announced. + // comparing them could not tell from never having been announced. An earlier + // run's announcement counts: a child resumed after a restart keeps that run's + // spawn id on its frames, and its rows recall the id that run resolved. const announced = this.deps.ids.isAnnounced(parentToolUseId) if ( !settled && @@ -132,8 +134,9 @@ export class ClaudeSubagentLinkage implements ClaudeSubagentLinkageSource { ) } // Nothing is coming for this id: nested tool traffic, a grandchild inside a - // sidechain, or a forwarded spawn call that can wait no longer. The raw - // reference is the only handle there will ever be for it. + // sidechain, a forwarded spawn call that can wait no longer, or a resumed + // child no journaled row ever resolved. The raw reference is the only handle + // there will ever be for it. return linked( parentToolUseId, canonical, diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts new file mode 100644 index 00000000000..ef6c067db9e --- /dev/null +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -0,0 +1,532 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { + isBackgroundTaskBlock, + isSubagentGroupBlock, + type NativeChatSubagentEntry +} from '../../shared/native-chat-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' + +// Frame orders are real sessions', scrubbed. A resumed agent's frames still name its ORIGINAL +// spawn call while the announcement names the message call that resumed it, and a new provider +// run knows neither unless it reads what the earlier run journaled. + +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-restart-')) +}) + +afterEach(async () => { + vi.useRealTimers() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function frame(message: Record, startsTurn = false) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(startsTurn ? { startsTurn: true as const } : {}), + message: { session_id: 'claude-session', parent_tool_use_id: null, ...message } + } +} + +const userTurn = (uuid: string) => + frame( + { type: 'user', uuid, message: { role: 'user', content: [{ type: 'text', text: 'go' }] } }, + true + ) + +const toolCall = (uuid: string, id: string, name: string, input: Record) => + frame({ + type: 'assistant', + uuid, + message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }) + +const toolResult = (uuid: string, toolUseId: string, content: string) => + frame({ + type: 'user', + uuid, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content }] } + }) + +const taskStarted = (taskId: string, toolUseId: string, description: string) => + frame({ + type: 'system', + subtype: 'task_started', + uuid: `started-${toolUseId}`, + task_id: taskId, + tool_use_id: toolUseId, + description, + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + +const taskFailed = (taskId: string, toolUseId: string) => + frame({ + type: 'system', + subtype: 'task_notification', + uuid: `failed-${taskId}`, + task_id: taskId, + tool_use_id: toolUseId, + status: 'failed' + }) + +const taskCompleted = (taskId: string, toolUseId: string) => + frame({ + type: 'system', + subtype: 'task_notification', + uuid: `completed-${taskId}`, + task_id: taskId, + tool_use_id: toolUseId, + status: 'completed' + }) + +/** A call a CHILD makes: its frame names the child's own spawn call as parent. */ +const childToolCall = ( + uuid: string, + parentToolUseId: string, + id: string, + input: Record +) => + frame({ + type: 'assistant', + uuid, + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Agent', input }] } + }) + +const childSays = (uuid: string, parentToolUseId: string, text: string) => + frame({ + type: 'assistant', + uuid, + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content: [{ type: 'text', text }] } + }) + +const result = (uuid: string) => + frame({ type: 'result', subtype: 'success', is_error: false, result: 'done', uuid }) + +async function openJournal(): Promise { + return journals.open({ + identity: { + sessionId: 'orca-session', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + }, + now: () => 9_000, + stateDirectory: root + }) +} + +/** One provider process: a fresh sink and translator over the session's journal. */ +function acquire(journal: AgentSessionJournal) { + const deferred = createDeferredStructuredAgentSessionEventSink() + const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) + deferred.bind({ journal, fence: 1, publish: () => {} }) + const settle = async (): Promise => { + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + } + const exit = async (): Promise => { + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + await settle() + translator.dispose() + deferred.close() + } + return { translator, settle, exit } +} + +const proseRow = (journal: AgentSessionJournal, text: string): AgentJournalRenderItem | undefined => + journal + .snapshot() + .items.find( + (item) => + item.body.kind === 'message' && + item.body.blocks.some((block) => block.type === 'text' && block.text === text) + ) + +type GroupRow = { groupId: string; revision: number; agents: NativeChatSubagentEntry[] } + +/** Every spawn-group row, as a reader sees it now. */ +const groupRows = (journal: AgentSessionJournal): GroupRow[] => + journal.snapshot().items.flatMap((item) => { + const block = item.body.kind === 'message' ? item.body.blocks.find(isSubagentGroupBlock) : null + return block ? [{ groupId: block.groupId, revision: item.revision, agents: block.agents }] : [] + }) + +const rowsListing = (journal: AgentSessionJournal, agentId: string): GroupRow[] => + groupRows(journal).filter((row) => row.agents.some((agent) => agent.id === agentId)) + +/** Every task a background-task row names. */ +const backgroundTaskIds = (journal: AgentSessionJournal): string[] => + journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (isBackgroundTaskBlock(block) ? [block.taskId] : [])) + : [] + ) + +/** Every id any spawn-group row names: what a transcript can put a name on. */ +const rosterIds = (journal: AgentSessionJournal): Set => + new Set(groupRows(journal).flatMap((row) => row.agents.map((agent) => agent.id))) + +/** An older build re-rostered a resumed child in the later turn's row, so two rows list it. */ +async function journalAnOlderBuildListedTwice(): Promise { + const journal = await openJournal() + const older = createDeferredStructuredAgentSessionEventSink() + older.bind({ journal, fence: 1, publish: () => {} }) + const listed = (id: string, state: NativeChatSubagentEntry['state']) => ({ + id, + label: `Child ${id}`, + state, + startedAt: 1, + settledAt: 2 + }) + for (const [groupId, agents] of [ + [ + 'claude-session:turn-a', + [listed('agent-a', 'unverifiable'), listed('agent-b', 'unverifiable')] + ], + ['claude-session:turn-b', [listed('agent-a', 'completed')]] + ] as const) { + older.sink.appendItem( + claudeSubagentGroupIdentity(groupId), + claudeSubagentGroupBody(groupId, agents), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await expect(older.drained()).resolves.toEqual({ ok: true }) + older.close() + return journal +} + +const agentAStateByRow = (journal: AgentSessionJournal): Record => + Object.fromEntries( + rowsListing(journal, 'agent-a').map((row) => [ + row.groupId, + row.agents.find((agent) => agent.id === 'agent-a')?.state + ]) + ) + +describe('a Claude subagent resumed after its provider restarted', () => { + it('keeps the canonical id its first run was given, so one child stays one child', async () => { + const journal = await openJournal() + + const first = acquire(journal) + first.translator.handle(userTurn('turn-a')) + first.translator.handle( + toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { + description: 'Follow-up A', + run_in_background: true + }) + ) + first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Follow-up A')) + first.translator.handle(toolResult('spawned-a', 'toolu_spawn_a', 'Async agent launched.')) + first.translator.handle(childSays('a-1', 'toolu_spawn_a', 'A reads the rig recipes')) + first.translator.handle( + toolCall('spawn-b', 'toolu_spawn_b', 'Agent', { + description: 'Follow-up B', + run_in_background: true + }) + ) + first.translator.handle(taskStarted('agent-b', 'toolu_spawn_b', 'Follow-up B')) + first.translator.handle(toolResult('spawned-b', 'toolu_spawn_b', 'Async agent launched.')) + first.translator.handle(childSays('b-1', 'toolu_spawn_b', 'B inspects the worktree')) + first.translator.handle(taskFailed('agent-b', 'toolu_spawn_b')) + first.translator.handle(taskFailed('agent-a', 'toolu_spawn_a')) + first.translator.handle(result('result-a')) + await first.exit() + expect(proseRow(journal, 'A reads the rig recipes')).toMatchObject({ agentId: 'agent-a' }) + + const second = acquire(journal) + second.translator.handle(userTurn('turn-b')) + second.translator.handle( + toolCall('resume-a', 'toolu_message_a', 'SendMessage', { to: 'agent-a', message: 'go on' }) + ) + second.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Follow-up A')) + second.translator.handle(toolResult('resumed-a', 'toolu_message_a', '{"success":true}')) + second.translator.handle( + toolCall('resume-b', 'toolu_message_b', 'SendMessage', { to: 'agent-b', message: 'go on' }) + ) + second.translator.handle(taskStarted('agent-b', 'toolu_message_b', 'Follow-up B')) + second.translator.handle(toolResult('resumed-b', 'toolu_message_b', '{"success":true}')) + // Interleaved, as the two resumed agents were: neither may take the other's id. + second.translator.handle(childSays('a-2', 'toolu_spawn_a', 'A restarts the receiver')) + second.translator.handle(childSays('b-2', 'toolu_spawn_b', 'B checks the rig')) + await second.settle() + + expect(proseRow(journal, 'A restarts the receiver')).toMatchObject({ + agentId: 'agent-a', + providerParentRef: 'toolu_spawn_a', + producerKind: 'agent' + }) + expect(proseRow(journal, 'B checks the rig')).toMatchObject({ + agentId: 'agent-b', + providerParentRef: 'toolu_spawn_b' + }) + // The resume revised the child's own entry, as it would have without a restart: one row names + // it, and its rows say which run of it they are. + expect(rowsListing(journal, 'agent-a')).toEqual([ + expect.objectContaining({ + groupId: 'claude-session:turn-a', + agents: [ + expect.objectContaining({ id: 'agent-a', state: 'working' }), + expect.objectContaining({ id: 'agent-b', state: 'working' }) + ] + }) + ]) + expect(proseRow(journal, 'A reads the rig recipes')?.attempt).toBeUndefined() + expect(proseRow(journal, 'A restarts the receiver')?.attempt).toBe(2) + expect(proseRow(journal, 'B checks the rig')?.attempt).toBe(2) + // What a reader sees: every child row names an agent a roster can name. + const named = rosterIds(journal) + const childRows = journal.snapshot().items.filter((item) => item.agentId !== undefined) + expect(childRows.length).toBeGreaterThan(0) + expect(childRows.filter((item) => !named.has(item.agentId ?? ''))).toEqual([]) + await second.exit() + }) + + it('keeps the earlier run’s children in the roster row no turn owns', async () => { + // A backgrounded child spawns its own child after the parent's turn has ended, so the + // grandchild is rostered outside any turn; the next provider run does the same again. + const journal = await openJournal() + const first = acquire(journal) + first.translator.handle(userTurn('turn-a')) + first.translator.handle( + toolCall('spawn-c', 'toolu_spawn_c', 'Agent', { + description: 'Readiness', + run_in_background: true + }) + ) + first.translator.handle(taskStarted('agent-c', 'toolu_spawn_c', 'Readiness')) + first.translator.handle(toolResult('spawned-c', 'toolu_spawn_c', 'Async agent launched.')) + first.translator.handle(result('result-a')) + first.translator.handle( + childToolCall('c-spawn', 'toolu_spawn_c', 'toolu_spawn_x', { description: 'Sweep leases' }) + ) + first.translator.handle(taskStarted('agent-x', 'toolu_spawn_x', 'Sweep leases')) + first.translator.handle(childSays('x-1', 'toolu_spawn_x', 'X reads the hydration')) + first.translator.handle(taskCompleted('agent-x', 'toolu_spawn_x')) + first.translator.handle(taskCompleted('agent-c', 'toolu_spawn_c')) + await first.exit() + expect(rowsListing(journal, 'agent-x')).toEqual([ + expect.objectContaining({ groupId: 'outside-turn' }) + ]) + + const second = acquire(journal) + second.translator.handle(userTurn('turn-b')) + second.translator.handle( + toolCall('spawn-d', 'toolu_spawn_d', 'Agent', { + description: 'Checklist', + run_in_background: true + }) + ) + second.translator.handle(taskStarted('agent-d', 'toolu_spawn_d', 'Checklist')) + second.translator.handle(toolResult('spawned-d', 'toolu_spawn_d', 'Async agent launched.')) + second.translator.handle(result('result-b')) + second.translator.handle( + childToolCall('d-spawn', 'toolu_spawn_d', 'toolu_spawn_y', { description: 'Run checklist' }) + ) + second.translator.handle(taskStarted('agent-y', 'toolu_spawn_y', 'Run checklist')) + await second.settle() + + const outside = groupRows(journal).filter((row) => row.groupId === 'outside-turn') + expect(outside).toEqual([ + expect.objectContaining({ + agents: [ + expect.objectContaining({ id: 'agent-x', label: 'Sweep leases', state: 'completed' }), + expect.objectContaining({ id: 'agent-y', label: 'Run checklist', state: 'working' }) + ] + }) + ]) + expect(proseRow(journal, 'X reads the hydration')?.agentId).toBe('agent-x') + expect(rosterIds(journal).has('agent-x')).toBe(true) + await second.exit() + }) + + it('takes Claude’s verdict on a child the earlier run lost contact with, and reopens it on an announcement', async () => { + // The provider exited while its backgrounded child still ran, so the roster said contact was + // lost. The next run is told the child did not finish, works on its own, then messages it. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(1_000_000) + const journal = await openJournal() + const first = acquire(journal) + first.translator.handle(userTurn('turn-a')) + first.translator.handle( + toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { + description: 'Grok PR', + run_in_background: true + }) + ) + first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR')) + first.translator.handle(toolResult('spawned-a', 'toolu_spawn_a', 'Async agent launched.')) + first.translator.handle(childSays('a-1', 'toolu_spawn_a', 'A reads the docs')) + first.translator.handle(result('result-a')) + await first.exit() + const [lost] = rowsListing(journal, 'agent-a') + expect(lost?.agents).toEqual([ + expect.objectContaining({ id: 'agent-a', state: 'unverifiable' }) + ]) + const lostAt = lost?.agents[0]?.settledAt + expect(lostAt).toBe(1_000_000) + + // The restart comes much later: that is not when the child stopped. + vi.setSystemTime(5_000_000) + const second = acquire(journal) + second.translator.handle(userTurn('turn-b')) + second.translator.handle( + frame({ + type: 'system', + subtype: 'task_notification', + uuid: 'unfinished-a', + task_id: 'agent-a', + status: 'stopped', + summary: 'Background agent "Grok PR" didn\'t finish before the previous session ended' + }) + ) + second.translator.handle(toolCall('bash-1', 'toolu_bash_1', 'Bash', { command: 'git status' })) + second.translator.handle(toolResult('bashed-1', 'toolu_bash_1', 'clean')) + await second.settle() + // The child's own row takes the verdict, keeping when that run lost it; no second row names it. + expect(rowsListing(journal, 'agent-a')).toEqual([ + expect.objectContaining({ + groupId: 'claude-session:turn-a', + agents: [expect.objectContaining({ id: 'agent-a', state: 'stopped', settledAt: lostAt })] + }) + ]) + expect(backgroundTaskIds(journal)).toEqual([]) + + // The resume is a new run: its clock starts now, not at the first run's start. + vi.setSystemTime(5_100_000) + second.translator.handle( + toolCall('resume-a', 'toolu_message_a', 'SendMessage', { to: 'agent-a', message: 'resume' }) + ) + second.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Grok PR')) + second.translator.handle(childSays('a-2', 'toolu_spawn_a', 'A resumes the capture')) + await second.settle() + expect(rowsListing(journal, 'agent-a')).toEqual([ + expect.objectContaining({ + groupId: 'claude-session:turn-a', + agents: [expect.objectContaining({ id: 'agent-a', state: 'working', startedAt: 5_100_000 })] + }) + ]) + expect(rowsListing(journal, 'agent-a')[0]?.agents[0]?.settledAt).toBeUndefined() + expect(proseRow(journal, 'A resumes the capture')).toMatchObject({ + agentId: 'agent-a', + attempt: 2 + }) + await second.exit() + }) + + it('invents no stop time for a child whose host died without sweeping it', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(1_000_000) + const crashed = await openJournal() + const first = acquire(crashed) + first.translator.handle(userTurn('turn-a')) + first.translator.handle( + toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { + description: 'Grok PR', + run_in_background: true + }) + ) + first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR')) + await first.settle() + // The host dies: nothing sweeps the child, and reopening the journal can say only that + // contact was lost, not when. + await crashed.close() + const journal = await openJournal() + const [lost] = rowsListing(journal, 'agent-a')[0]?.agents ?? [] + expect(lost).toMatchObject({ id: 'agent-a', state: 'unverifiable' }) + expect(lost?.settledAt).toBeUndefined() + + vi.setSystemTime(5_000_000) + const second = acquire(journal) + second.translator.handle(userTurn('turn-b')) + second.translator.handle( + frame({ + type: 'system', + subtype: 'task_notification', + uuid: 'unfinished-a', + task_id: 'agent-a', + status: 'stopped' + }) + ) + await second.settle() + const [stopped] = rowsListing(journal, 'agent-a')[0]?.agents ?? [] + expect(stopped).toMatchObject({ id: 'agent-a', state: 'stopped' }) + expect(stopped?.settledAt).toBeUndefined() + await second.exit() + }) + + it('keeps one live entry for a child an older build listed in two rows', async () => { + const journal = await journalAnOlderBuildListedTwice() + const run = acquire(journal) + run.translator.handle(userTurn('turn-c')) + run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) + // A frame for a sibling only the other row lists brings that row in too. + run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) + run.translator.handle(taskCompleted('agent-a', 'toolu_message_a')) + await run.settle() + + // The copy the resume reopened is the one its outcome lands on; nothing is left running. + expect(agentAStateByRow(journal)).toEqual({ + 'claude-session:turn-a': 'unverifiable', + 'claude-session:turn-b': 'completed' + }) + await run.exit() + }) + + it('resumes a twice-listed child in its later row even when a sibling reaches the older row first', async () => { + const journal = await journalAnOlderBuildListedTwice() + const run = acquire(journal) + run.translator.handle(userTurn('turn-c')) + run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) + run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) + await run.settle() + + expect(agentAStateByRow(journal)).toEqual({ + 'claude-session:turn-a': 'unverifiable', + 'claude-session:turn-b': 'working' + }) + await run.exit() + }) + + it('still files a child no journaled row resolved under its own reference, never the parent', async () => { + const journal = await openJournal() + const first = acquire(journal) + first.translator.handle(userTurn('turn-a')) + first.translator.handle(result('result-a')) + await first.exit() + + const second = acquire(journal) + second.translator.handle(userTurn('turn-b')) + second.translator.handle(taskStarted('agent-other', 'toolu_other', 'Other')) + second.translator.handle(childSays('c-1', 'toolu_unknown', 'nobody named me')) + await second.settle() + + // A control: nothing recalled for an id the journal never resolved, so no id is invented. + expect(proseRow(journal, 'nobody named me')).toMatchObject({ + agentId: 'toolu_unknown', + providerParentRef: 'toolu_unknown' + }) + await second.exit() + }) +}) diff --git a/src/main/claude/claude-subagent-roster-groups.test.ts b/src/main/claude/claude-subagent-roster-groups.test.ts new file mode 100644 index 00000000000..0a790f44111 --- /dev/null +++ b/src/main/claude/claude-subagent-roster-groups.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { JournaledClaudeSubagentGroup } from './claude-subagent-group-row' +import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' +import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups' + +const entry = (id: string): NativeChatSubagentEntry => ({ + id, + label: `Child ${id}`, + state: 'unverifiable', + startedAt: 1, + settledAt: 2 +}) + +const turnScope = (groupId: string): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: `claude:claude-session:${groupId}` +}) + +/** An older build listed `agent-a` in both rows; the reading places it in the later one. */ +function journaledTwiceListed(): ClaudeJournaledRosterSource { + const rows = new Map([ + ['turn-a', { entries: [entry('agent-a'), entry('agent-b')], turnScope: turnScope('turn-a') }], + ['turn-b', { entries: [entry('agent-a')], turnScope: turnScope('turn-b') }] + ]) + const groupOf = new Map([ + ['agent-a', 'turn-b'], + ['agent-b', 'turn-a'] + ]) + return { + canonical: () => null, + groupOf: (id) => groupOf.get(id) ?? null, + claimGroup: (groupId) => { + const row = rows.get(groupId) ?? null + rows.delete(groupId) + return row + }, + attempt: () => 1 + } +} + +describe('ClaudeSubagentRosterGroups', () => { + it('places a twice-listed child where the reading does, whichever row is reached first', () => { + for (const order of [ + ['agent-a', 'agent-b'], + ['agent-b', 'agent-a'] + ]) { + const groups = new ClaudeSubagentRosterGroups({ + journaled: journaledTwiceListed(), + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, + onEvicted: () => {} + }) + for (const id of order) { + groups.locateOrInherit(id) + } + expect(groups.locate('agent-a')?.group.groupId).toBe('turn-b') + expect(groups.locate('agent-b')?.group.groupId).toBe('turn-a') + } + }) + + it('keeps a child placed in a held row when another row listing it is evicted', () => { + const groups = new ClaudeSubagentRosterGroups({ + journaled: journaledTwiceListed(), + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, + onEvicted: () => {} + }) + groups.locateOrInherit('agent-b') + groups.locateOrInherit('agent-a') + // Enough of this run's own rows to push the older inherited row out. + for (let turn = 0; turn < 31; turn += 1) { + groups.groupFor(`turn-${turn}-new`) + } + expect(groups.get('turn-a')).toBeUndefined() + expect(groups.locate('agent-b')).toBeNull() + expect(groups.locate('agent-a')?.group.groupId).toBe('turn-b') + }) + + it('keeps an inherited row beside the turn that wrote it, and puts a new row beside the open turn', () => { + const live = turnScope('turn-live') + const groups = new ClaudeSubagentRosterGroups({ + journaled: journaledTwiceListed(), + currentTurnScope: () => live, + onEvicted: () => {} + }) + expect(groups.locateOrInherit('agent-b')?.group.turnScope).toEqual(turnScope('turn-a')) + expect(groups.groupFor('turn-live').turnScope).toEqual(live) + }) +}) diff --git a/src/main/claude/claude-subagent-roster-groups.ts b/src/main/claude/claude-subagent-roster-groups.ts new file mode 100644 index 00000000000..ea4ce76cdfb --- /dev/null +++ b/src/main/claude/claude-subagent-roster-groups.ts @@ -0,0 +1,134 @@ +// Which spawn group holds each Claude subagent: the groups this provider run +// wrote, and the ones earlier runs of the session journaled, inherited one at a +// time as this run's own events reach them. + +import { + claudeSubagentGroupIdentity, + inheritedClaudeSubagentGroup +} from './claude-subagent-group-row' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' +import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' +import type { RosterGroup, TrackedEntry } from './claude-subagent-roster-state' + +/** Spawn-group rows kept live per session. Bounds an event-accumulated map that + * no provider snapshot ever prunes. */ +const MAX_SUBAGENT_GROUPS = 32 + +export type LocatedClaudeSubagent = { group: RosterGroup; tracked: TrackedEntry } + +export class ClaudeSubagentRosterGroups { + private readonly groups = new Map() + /** Canonical id → the group holding its entry, so a late update for a child + * from an earlier turn revises that turn's row instead of the live one. */ + private readonly groupIdByEntry = new Map() + + constructor( + private readonly deps: { + journaled?: ClaudeJournaledRosterSource + /** The open turn's scope, which a group this run creates belongs to. */ + currentTurnScope: () => AgentJournalTurnScope + /** Once a group leaves the map nothing can reach its children again — not + * even a session sweep — so contact is lost here. */ + onEvicted: (group: RosterGroup) => void + } + ) {} + + get(groupId: string): RosterGroup | undefined { + return this.groups.get(groupId) + } + + values(): Iterable { + return this.groups.values() + } + + locate(id: string): LocatedClaudeSubagent | null { + const groupId = this.groupIdByEntry.get(id) + const group = groupId === undefined ? undefined : this.groups.get(groupId) + const tracked = group?.entries.get(id) + return group && tracked ? { group, tracked } : null + } + + /** Finds a child, inheriting the group an earlier run last listed it in. */ + locateOrInherit(id: string): LocatedClaudeSubagent | null { + const located = this.locate(id) + if (located) { + return located + } + const groupId = this.deps.journaled?.groupOf(id) ?? null + return groupId !== null && !this.groups.has(groupId) && this.inherit(groupId) + ? this.locate(id) + : null + } + + /** This run's group for a key, else the earlier run's row it continues — the + * key no turn owns is reused across runs — else a new one. */ + groupFor(groupId: string): RosterGroup { + const existing = this.groups.get(groupId) ?? this.inherit(groupId) + if (existing) { + return existing + } + const group: RosterGroup = { + groupId, + identity: claudeSubagentGroupIdentity(groupId), + turnScope: this.deps.currentTurnScope(), + entries: new Map(), + admittedEntries: 0, + claimedLabels: new Set(), + lastSerialized: null + } + this.admit(group) + return group + } + + place(id: string, groupId: string): void { + this.groupIdByEntry.set(id, groupId) + } + + forget(id: string): void { + this.groupIdByEntry.delete(id) + } + + clear(): void { + this.groups.clear() + this.groupIdByEntry.clear() + } + + /** Once per group: after that this run's copy is the newer one. */ + private inherit(groupId: string): RosterGroup | null { + const journaled = this.deps.journaled + const row = journaled?.claimGroup(groupId) ?? null + if (!journaled || !row) { + return null + } + const group = inheritedClaudeSubagentGroup(groupId, row, journaled.attempt) + this.admit(group) + for (const id of group.entries.keys()) { + // A child an older build listed in two rows lives only in the one the reading chose, whichever + // row this run's frames reach first; the other copy is history. + if (journaled.groupOf(id) === groupId) { + this.groupIdByEntry.set(id, groupId) + } + } + return group + } + + private admit(group: RosterGroup): void { + this.groups.set(group.groupId, group) + while (this.groups.size > MAX_SUBAGENT_GROUPS) { + const oldest = this.groups.keys().next() + if (oldest.done || oldest.value === group.groupId) { + break + } + const evicted = this.groups.get(oldest.value) + if (evicted) { + this.deps.onEvicted(evicted) + } + for (const id of evicted?.entries.keys() ?? []) { + if (this.groupIdByEntry.get(id) === oldest.value) { + this.groupIdByEntry.delete(id) + } + } + this.groups.delete(oldest.value) + } + } +} diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts index f7fc3374661..3e4b2fd6fde 100644 --- a/src/main/claude/claude-subagent-roster-state.ts +++ b/src/main/claude/claude-subagent-roster-state.ts @@ -1,4 +1,7 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' @@ -17,11 +20,19 @@ export type TrackedEntry = { /** Which run of this child. Identity survives a resume by design, so without * this the retained rows of two runs read as one uninterrupted timeline. */ attempt: number + /** Inherited from a row an earlier provider run journaled, and not announced in + * this run yet. That run's calls are unknown here, so any announcement is a new + * invocation, and only an announcement reopens what that run settled. Any other + * frame can still give that run's outcome, such as Claude's "didn't finish before + * the previous session ended". */ + invokedInEarlierRun: boolean } export type RosterGroup = { groupId: string identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope /** Insertion order is the display order; the map holds the state. */ entries: Map /** Lifetime admissions bound retained labels even when entries are removed. */ @@ -41,6 +52,18 @@ export function applyClaudeSubagentInvocation( if (tracked.invocationIds === null) { return false } + if (tracked.invokedInEarlierRun) { + if (!frame.announcement) { + // A verdict on that run, not an invocation of this one; the latch still guards it. + return true + } + reopen(tracked, frame, now) + if (frame.toolUseId) { + tracked.invocationIds.add(frame.toolUseId) + tracked.toolUseId = frame.toolUseId + } + return true + } const newInvocation = frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId) if (newInvocation && frame.toolUseId) { @@ -51,12 +74,10 @@ export function applyClaudeSubagentInvocation( } tracked.invocationIds.add(frame.toolUseId) if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) { - // THE reactivation: a new spawn alias reopening this entry. The one place - // the attempt moves, and it is gated on the observed alias change rather - // than on the counter, so a late duplicate cannot advance a settled run. - tracked.attempt += 1 - tracked.backgrounded = frame.backgrounded ?? false - tracked.entry = { ...tracked.entry, state: frame.state ?? 'working', settledAt: undefined } + // THE reactivation: a new spawn alias reopening this entry. Gated on the + // observed alias change rather than on the counter, so a late duplicate + // cannot advance a settled run. + reopen(tracked, frame, now) } tracked.toolUseId = frame.toolUseId } else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) { @@ -68,6 +89,20 @@ export function applyClaudeSubagentInvocation( return true } +/** The one place the attempt moves. A new run starts its own clock, so the + * idle time since the last run never reads as run time. */ +function reopen(tracked: TrackedEntry, frame: ClaudeSubagentTaskFrame, now: () => number): void { + tracked.invokedInEarlierRun = false + tracked.attempt += 1 + tracked.backgrounded = frame.backgrounded ?? false + tracked.entry = { + ...tracked.entry, + state: frame.state ?? 'working', + startedAt: now(), + settledAt: undefined + } +} + /** Two children can share a description; the ordinal keeps their rows apart * without inventing a name the provider never sent. The probe is over the * labels actually rendered, not a per-base counter: a generated `Audit 2` diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index da1f16d0a0e..34ff506ee03 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -39,11 +40,13 @@ function harness(groupKey: string | null = TURN_1) { publish: vi.fn() } let clock = 1_000 + let pinnedClock: number | null = null let key = groupKey const roster = new ClaudeSubagentRoster({ sink, currentGroupKey: () => key, - now: () => (clock += 1) + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, + now: () => pinnedClock ?? (clock += 1) }) const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) /** The last row written for one group, so a test can read a row that is no @@ -58,6 +61,9 @@ function harness(groupKey: string | null = TURN_1) { rolesIn, setGroupKey: (next: string | null) => { key = next + }, + pinClock: (at: number) => { + pinnedClock = at } } } @@ -464,7 +470,11 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { published += 1 } }) - const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 }) + const roster = new ClaudeSubagentRoster({ + sink: deferred.sink, + currentGroupKey: () => TURN_1, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) // The first append is in flight while the rest are submitted, so a publish // sharing the row's coalescing key would evict them. @@ -517,14 +527,21 @@ describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', describe('ClaudeSubagentRoster — resumed invocation', () => { it('reopens one canonical child on a new announcement without replaying old results', () => { - const { roster, roles } = harness() + const { roster, roles, pinClock } = harness() roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) roster.observeToolResult('first', false) + // Resumed long after its first run: the new run starts its own clock. + pinClock(900_000) roster.observeSystemFrame( started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) ) - expect(roles()).toEqual([expect.objectContaining({ id: 'task-1', state: 'working' })]) + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', state: 'working', startedAt: 900_000 }) + ]) expect(roles()[0].settledAt).toBeUndefined() + expect(roster.linkage.settledLinkageFor('resumed')).toMatchObject({ + linkage: { agentId: 'task-1', attempt: 2 } + }) roster.observeSystemFrame( system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' }) ) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts index d45a17e15e9..e3d291868fa 100644 --- a/src/main/claude/claude-subagent-roster.ts +++ b/src/main/claude/claude-subagent-roster.ts @@ -10,7 +10,12 @@ // the key and tool ids are aliases; keying on the tool id would duplicate the // child on every resume. Outcomes latch within an invocation; a new spawn // alias can reopen it, and authoritative evidence can correct lost contact. +// +// The state is one provider run's, but its rows are the session's. A run +// inherits what earlier runs journaled, one group at a time as its own events +// reach it, so a restart neither splits a child nor erases a row's children. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { canReplaceSubagentState, isTerminalSubagentState @@ -18,11 +23,10 @@ import { import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { isBoundedClaudeTaskId } from './claude-background-task-tracker' -import { - claudeSubagentGroupIdentity, - writeClaudeSubagentGroupRow -} from './claude-subagent-group-row' +import { writeClaudeSubagentGroupRow } from './claude-subagent-group-row' import { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' +import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups' import { ClaudeSubagentLinkage, type ClaudeSubagentLinkageSource } from './claude-subagent-linkage' import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' import { @@ -32,9 +36,8 @@ import { type TrackedEntry } from './claude-subagent-roster-state' -/** Spawn-group rows kept live per session, and children per row. Both bound an - * event-accumulated map that no provider snapshot ever prunes. */ -const MAX_SUBAGENT_GROUPS = 32 +/** Children per spawn-group row. Bounds an event-accumulated map that no + * provider snapshot ever prunes. */ const MAX_SUBAGENTS_PER_GROUP = 64 /** The turn a group belongs to when Claude reports a task outside any turn. */ @@ -46,6 +49,8 @@ export type ClaudeSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The turn that owns children spawned right now; null outside any turn. */ currentGroupKey: () => string | null + /** That turn's scope, which the roster row it spawns belongs to. */ + currentTurnScope: () => AgentJournalTurnScope /** Whether a tool id was forwarded at the TOP level. A child parented to one * was spawned by a call the transcript shows, so its announcement is still * expected; a child parented to anything else names an id that only ever @@ -54,6 +59,8 @@ export type ClaudeSubagentRosterDeps = { /** The reference naming the child that journaled a tool call, when a child * did. It is how a grandchild's row reaches the agent that spawned it. */ childOwnerRefOf?: (toolUseId: string) => string | null + /** What earlier provider runs of this session journaled of this roster. */ + journaled?: ClaudeJournaledRosterSource /** A settled group can receive no further announcement, so an identity still * provisional will stay that way. Fires on EVERY settle path, so a caller * holding rows against a pending identity cannot miss one. */ @@ -62,11 +69,8 @@ export type ClaudeSubagentRosterDeps = { } export class ClaudeSubagentRoster { - private readonly groups = new Map() - /** Canonical id → the group holding its entry, so a late update for a child - * from an earlier turn revises that turn's row instead of the live one. */ - private readonly groupIdByEntry = new Map() - private readonly ids = new ClaudeSubagentIds() + private readonly groups: ClaudeSubagentRosterGroups + private readonly ids: ClaudeSubagentIds /** Who produced a row, for every write site journaling this session. */ readonly linkage: ClaudeSubagentLinkageSource /** Set by ANY `task_started`, including one the subagent filter rejects. Once @@ -77,9 +81,15 @@ export class ClaudeSubagentRoster { constructor(private readonly deps: ClaudeSubagentRosterDeps) { this.now = deps.now ?? (() => Date.now()) + this.groups = new ClaudeSubagentRosterGroups({ + journaled: deps.journaled, + currentTurnScope: deps.currentTurnScope, + onEvicted: (group) => this.sweep(group, true) + }) + this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) this.linkage = new ClaudeSubagentLinkage({ ids: this.ids, - trackedFor: (canonicalId) => this.locate(canonicalId)?.tracked ?? null, + trackedFor: (canonicalId) => this.groups.locate(canonicalId)?.tracked ?? null, isForwardedParentTool: deps.isForwardedParentTool, childOwnerRefOf: deps.childOwnerRefOf }) @@ -110,7 +120,7 @@ export class ClaudeSubagentRoster { this.ids.alias(frame.toolUseId, frame.taskId) } const located = - this.locate(frame.taskId) ?? + this.groups.locateOrInherit(frame.taskId) ?? (frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null) if (!located) { if (frame.announcesSubagent) { @@ -146,7 +156,7 @@ export class ClaudeSubagentRoster { if (this.ids.isExcluded(parentToolUseId, canonical)) { return } - if (this.locate(canonical)) { + if (this.groups.locateOrInherit(canonical)) { return } if (this.announcesTasks) { @@ -174,7 +184,7 @@ export class ClaudeSubagentRoster { */ observeToolResult(toolUseId: string, failed: boolean): void { const canonical = this.ids.canonical(toolUseId) - const located = this.locate(canonical) + const located = this.groups.locate(canonical) if ( !located || located.tracked.invocationIds === null || @@ -219,7 +229,6 @@ export class ClaudeSubagentRoster { // did settle first leaves every child terminal, so this writes nothing. this.settleSession() this.groups.clear() - this.groupIdByEntry.clear() this.ids.clear() this.announcesTasks = false } @@ -254,7 +263,7 @@ export class ClaudeSubagentRoster { backgrounded: boolean, toolUseId: string | null ): void { - const group = this.groupFor() + const group = this.groups.groupFor(this.deps.currentGroupKey() ?? OUTSIDE_TURN) if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { return } @@ -267,6 +276,7 @@ export class ClaudeSubagentRoster { invocationIds: new Set(toolUseId ? [toolUseId] : []), labelBase, attempt: 1, + invokedInEarlierRun: false, entry: { id, label: claimClaudeSubagentLabel(group, labelBase), @@ -275,7 +285,7 @@ export class ClaudeSubagentRoster { ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) } }) - this.groupIdByEntry.set(id, group.groupId) + this.groups.place(id, group.groupId) writeClaudeSubagentGroupRow(this.deps.sink, group) } @@ -311,7 +321,9 @@ export class ClaudeSubagentRoster { if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) { next.entry.state = change.state if (isTerminalSubagentState(change.state)) { - next.entry.settledAt = this.now() + // An earlier run's child ended with that run, so this verdict says how, not + // when; a run that died unswept left no time, and now is not it. + next.entry.settledAt = tracked.invokedInEarlierRun ? tracked.entry.settledAt : this.now() } } group.entries.set(id, next) @@ -324,7 +336,7 @@ export class ClaudeSubagentRoster { if (toolUseId === taskId) { return null } - const located = this.locate(toolUseId) + const located = this.groups.locate(toolUseId) if (!located) { return null } @@ -333,57 +345,18 @@ export class ClaudeSubagentRoster { ...located.tracked, entry: { ...located.tracked.entry, id: taskId } }) - this.groupIdByEntry.delete(toolUseId) - this.groupIdByEntry.set(taskId, located.group.groupId) + this.groups.forget(toolUseId) + this.groups.place(taskId, located.group.groupId) return { group: located.group } } private remove(id: string): void { - const located = this.locate(id) + const located = this.groups.locate(id) if (!located) { return } located.group.entries.delete(id) - this.groupIdByEntry.delete(id) + this.groups.forget(id) writeClaudeSubagentGroupRow(this.deps.sink, located.group) } - - private locate(id: string): { group: RosterGroup; tracked: TrackedEntry } | null { - const groupId = this.groupIdByEntry.get(id) - const group = groupId === undefined ? undefined : this.groups.get(groupId) - const tracked = group?.entries.get(id) - return group && tracked ? { group, tracked } : null - } - - private groupFor(): RosterGroup { - const groupId = this.deps.currentGroupKey() ?? OUTSIDE_TURN - const existing = this.groups.get(groupId) - if (existing) { - return existing - } - const group: RosterGroup = { - groupId, - identity: claudeSubagentGroupIdentity(groupId), - entries: new Map(), - admittedEntries: 0, - claimedLabels: new Set(), - lastSerialized: null - } - this.groups.set(groupId, group) - while (this.groups.size > MAX_SUBAGENT_GROUPS) { - const oldest = this.groups.keys().next() - if (oldest.done || oldest.value === groupId) { - break - } - const evicted = this.groups.get(oldest.value) - // Once the group leaves the map nothing can reach its children again — - // not even a session sweep — so contact is lost here. - this.sweep(evicted, true) - for (const id of evicted?.entries.keys() ?? []) { - this.groupIdByEntry.delete(id) - } - this.groups.delete(oldest.value) - } - return group - } } diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 2a31b49afdb..be7150531fe 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -7,6 +7,7 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' +import type { ClaudeCommandTurn } from './claude-command-turn' export type ClaudeCurrentTurn = { sessionId: string @@ -18,6 +19,13 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string + /** Present when the turn is the host's record of a conversation command. */ + command?: ClaudeCommandTurn +} + +/** The row a turn's lifecycle lives on: the host's record for a command, else the lane's own. */ +export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournalItemIdentity { + return turn.command?.identity ?? claudeTurnLifecycleIdentity(turn.sessionId, turn.turnId) } export type ClaudeTurnEnd = { @@ -84,7 +92,7 @@ export function claudeTurnLifecycleItem( // already carried, because both are built from the same open turn. const requested = requestedAt === undefined ? {} : { requestedAt } return { - identity: claudeTurnLifecycleIdentity(sessionId, turnId), + identity: claudeCurrentTurnIdentity(turn), body: agentJournalTurnBody( end ? { diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 81a84989aa4..6bfcb32cb86 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -6,11 +6,7 @@ import type { AgentJournalItemBody } from '../../shared/agent-session-journal-ty import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { - CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS, - cancelClaudeStructuredTurn -} from './claude-structured-prompt-ownership' +import { cancelClaudeStructuredTurn } from './claude-structured-prompt-ownership' import { sessionFor } from './claude-structured-dispatch-test-support' import { PROVIDER_SESSION_ID, @@ -99,6 +95,10 @@ function sessionHoldingTurn(turnId: string | null): ReturnType Promise<{ cancelled: boolean }>): Promise { + vi.useFakeTimers() + try { + let outcome: unknown = 'still waiting' + void stop().then((result) => (outcome = result)) + await vi.advanceTimersByTimeAsync(0) + return outcome + } finally { + vi.useRealTimers() + } +} + function cancellationOf( session: ReturnType, request: Parameters[0]['request'] @@ -119,7 +132,6 @@ function cancellationOf( return cancelClaudeStructuredTurn({ request, sessions: new Map([['session-1', session]]), - compactions: new StructuredSessionCompaction(), admitPromptCancellation: () => true }) } @@ -169,56 +181,8 @@ describe('Claude turn ownership', () => { expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) }) - it('keeps the prior dispatch fence after an unknown later send', async () => { - vi.useFakeTimers() - try { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - const sendFirst = connection.send - connection.send = async (message) => { - if (connection.sent.length > 0) { - throw new Error('input pump stopped') - } - await sendFirst(message) - } - - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-2', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - - const cancellation = adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'echo-turn', - fence: 7 - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS - 1) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(cancellation).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('lets a queued-cancel provider release an unresolved ordinary Stop', async () => { - const claude = fakeClaude({ - replayUuid: 'echo-turn', - capabilities: ['interrupt_cancel_queued_v1'] - }) + it('stops the running turn at once while a later send is unresolved', async () => { + const claude = fakeClaude({ replayUuid: 'echo-turn' }) const { adapter, bodies, connection } = await acquiredWithJournal(claude) await adapter.dispatch({ @@ -228,108 +192,38 @@ describe('Claude turn ownership', () => { fence: 7 }) expect(runningTurnId(bodies)).toBe('echo-turn') + const sendFirst = connection.send + connection.send = async (message) => { + if (connection.sent.length > 0) { + throw new Error('input pump stopped') + } + await sendFirst(message) + } await expect( - adapter.cancelTurn({ + adapter.dispatch({ sessionId: 'session-1', - turnId: 'echo-turn', - fence: 7, - dispatchStatus: { state: 'unknown', recovered: false } + clientMessageId: 'client-2', + body: USER_MESSAGE, + fence: 7 }) + ).resolves.toMatchObject({ state: 'unknown' }) + + await expect( + outcomeAtOnce(() => + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'echo-turn', fence: 7 }) + ) ).resolves.toEqual({ cancelled: true }) expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) }) - it('lets ordinary Stop proceed after the unresolved delivery fence expires', async () => { - vi.useFakeTimers() - try { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - - const cancellation = adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'echo-turn', - fence: 7, - dispatchStatus: { state: 'unknown', recovered: false } - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS - 1) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - - await vi.advanceTimersByTimeAsync(1) - await expect(cancellation).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('releases ordinary Stop as soon as a retired delivery fence settles', async () => { - vi.useFakeTimers() - try { - const session = sessionFor() - session.dispatchSequence = 1 - session.translator = { - handle: vi.fn(), - openTurnInLiveProviderCycle: false, - journalPrompts: { cancel: vi.fn(), resolve: vi.fn() }, - currentTurnId: 'turn-1', - flush: vi.fn(), - contextActivity: 0, - markContextActivity: vi.fn(), - subscribeContextUsageRequests: () => () => {}, - recordContextReport: () => {}, - modelMayHaveChanged: () => {}, - modelWritten: () => {}, - pendingStreamedBlocks: 0, - dispose: vi.fn() - } - session.retiredDispatchWaiters = [ - { - acceptsResult: false, - clientMessageId: 'client-2', - sentUuid: 'uncertain', - dispatchSequence: 1, - requestedAt: null, - replayContentKey: 'ship-it', - resolve: vi.fn(), - retired: true - } - ] - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt - const cancellation = cancelClaudeStructuredTurn({ - request: { sessionId: 'session-1', turnId: 'turn-1', fence: 1 }, - sessions: new Map([['session-1', session]]), - compactions: new StructuredSessionCompaction(), - admitPromptCancellation: () => true - }) - await vi.advanceTimersByTimeAsync(100) - session.retiredDispatchWaiters = [] - await vi.advanceTimersByTimeAsync(100) - const settledBeforeDeadline = interrupt.mock.calls.length > 0 - if (!settledBeforeDeadline) { - await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS) - await cancellation - } - expect(settledBeforeDeadline).toBe(true) - await expect(cancellation).resolves.toEqual({ cancelled: true }) - } finally { - vi.useRealTimers() - } - }) - - it('does not wait when the dispatch admission is already current', async () => { - vi.useFakeTimers() - try { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) + it.each([ + ['with cancel_queued', ['interrupt_cancel_queued_v1']], + ['without cancel_queued', []] + ])( + 'stops the running turn at once while its own delivery is unresolved, %s', + async (_label, capabilities) => { + const claude = fakeClaude({ replayUuid: 'echo-turn', capabilities }) const { adapter, bodies, connection } = await acquiredWithJournal(claude) await adapter.dispatch({ @@ -341,36 +235,61 @@ describe('Claude turn ownership', () => { expect(runningTurnId(bodies)).toBe('echo-turn') await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'echo-turn', fence: 7 }) + outcomeAtOnce(() => + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'echo-turn', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: false } + }) + ) ).resolves.toEqual({ cancelled: true }) expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - } finally { - vi.useRealTimers() } + ) + + it('stops the in-memory turn at once while a retired send is unresolved', async () => { + const session = sessionHoldingTurn('turn-1') + session.retiredDispatchWaiters = [ + { + acceptsResult: false, + clientMessageId: 'client-2', + sentUuid: 'uncertain', + dispatchSequence: 1, + requestedAt: null, + replayContentKey: 'ship-it', + resolve: vi.fn(), + retired: true + } + ] + const interrupt = vi.fn().mockResolvedValue(undefined) + session.connection.interrupt = interrupt + + await expect( + outcomeAtOnce(() => + cancellationOf(session, { sessionId: 'session-1', turnId: 'turn-1', fence: 1 }) + ) + ).resolves.toEqual({ cancelled: true }) + expect(interrupt).toHaveBeenCalledOnce() }) - it('honors an unresolved journal submission before the first in-memory dispatch', async () => { - vi.useFakeTimers() - try { - const claude = fakeClaude({ replayUuid: null }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - providerOutput(connection, 'provider-turn') - expect(runningTurnId(bodies)).toBe('provider-turn') + it('stops a provider-opened turn at once while the first journal submission is unresolved', async () => { + const claude = fakeClaude({ replayUuid: null }) + const { adapter, bodies, connection } = await acquiredWithJournal(claude) + providerOutput(connection, 'provider-turn') + expect(runningTurnId(bodies)).toBe('provider-turn') - const cancellation = adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'provider-turn', - fence: 7, - dispatchStatus: { state: 'pending', recovered: false } - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS - 1) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(cancellation).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - } finally { - vi.useRealTimers() - } + await expect( + outcomeAtOnce(() => + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'provider-turn', + fence: 7, + dispatchStatus: { state: 'pending', recovered: false } + }) + ) + ).resolves.toEqual({ cancelled: true }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) }) it('still stops an echo-opened turn', async () => { @@ -444,32 +363,23 @@ describe('Claude turn ownership', () => { expect(interrupt).not.toHaveBeenCalled() }) - // The guard re-checks after the delivery fence may have waited seconds, so the journal read - // has to happen then — a value captured at request time would interrupt whatever ran next. - it('re-reads the published turn after the delivery fence waits', async () => { - vi.useFakeTimers() - try { - let publishedTurnId = 'turn-shown' - const session = sessionHoldingTurn('turn-shown') - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt + it('refuses a Stop for an ended turn at once while a later send is unresolved', async () => { + const session = sessionHoldingTurn('turn-next') + const interrupt = vi.fn().mockResolvedValue(undefined) + session.connection.interrupt = interrupt - const cancellation = cancellationOf(session, { - sessionId: 'session-1', - turnId: 'turn-shown', - fence: 1, - dispatchStatus: { state: 'unknown', recovered: false }, - resolveLiveTurnId: () => publishedTurnId - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS - 1) - publishedTurnId = 'turn-next' - await vi.advanceTimersByTimeAsync(1) - - await expect(cancellation).resolves.toEqual({ cancelled: false }) - expect(interrupt).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + await expect( + outcomeAtOnce(() => + cancellationOf(session, { + sessionId: 'session-1', + turnId: 'turn-shown', + fence: 1, + dispatchStatus: { state: 'unknown', recovered: false }, + resolveLiveTurnId: () => 'turn-next' + }) + ) + ).resolves.toEqual({ cancelled: false }) + expect(interrupt).not.toHaveBeenCalled() }) it('refuses a stale turn id once the provider opened a newer turn', async () => { diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/claude/claude-turn-row-revision.ts index 1bee6bd0162..1b684ba89aa 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/claude/claude-turn-row-revision.ts @@ -14,9 +14,10 @@ import { agentJournalItemKey, parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' @@ -149,7 +150,7 @@ function findTurnRow( export type ClaudeTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean - options?: StructuredAgentSessionRevisionOptions + options?: Omit } /** @@ -161,8 +162,10 @@ export function writeClaudeTurnRow( sink: StructuredAgentSessionEventSink, target: ClaudeTurnRowTarget, write: ClaudeTurnRowWrite, - { publish, options = {} }: ClaudeTurnRowDelivery + { publish, options: delivery = {} }: ClaudeTurnRowDelivery ): void { + // A turn record belongs to no turn. + const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem if (!revise) { if (write.lifecycle && 'identity' in target) { diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index e6354826366..0fbf93adadb 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -220,22 +220,6 @@ describe('waitForManagedCodexAuthReady', () => { chmodSync(join(fixture.home, 'auth.json'), 0o000) expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('unreadable') }) - - it('does not gate system, WSL, or unmanaged custom homes', async () => { - const fixture = createFixture() - await waitForManagedCodexAuthReady({ - ...fixture.args, - codexHomePath: join(fixture.root, 'custom-home') - }) - await waitForManagedCodexAuthReady({ - ...fixture.args, - target: { runtime: 'wsl', wslDistro: 'Ubuntu' } - }) - await waitForManagedCodexAuthReady({ - ...fixture.args, - codexHomePath: null - }) - }) }) function createFixture(): { diff --git a/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts b/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts index 05deffd4fc2..7a86af57f04 100644 --- a/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts +++ b/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts @@ -289,8 +289,7 @@ describe('resolveHostCodexHomePathForLaunchReadOnly', () => { resolveStructuredCodexAccountHomePath({ launchEnv: {}, resolveLaunchHome: (input) => - service.resolveHostCodexHomePathForLaunchReadOnly(input.launchEnv), - workspacePath: '' + service.resolveHostCodexHomePathForLaunchReadOnly(input.launchEnv) }) ).resolves.toBe(getSystemCodexHomePath()) }) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 53736a11beb..4a7ad01e1b5 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -6,6 +6,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexManagedAccount } from '../../shared/managed-account-types' import type * as NodeOs from 'node:os' import { readHookTrustEntries } from '../codex/config-toml-trust' +import { writeCodexStateDbBackfillStatus } from '../codex/codex-state-db-test-fixture' const testState = { userData: '', home: '' } const previousEnv: Record = {} @@ -15,6 +16,17 @@ vi.mock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => testState.home } }) +// Why: selecting an account starts the history bridge, which would otherwise +// spawn the real `codex app-server` on these fixture homes. +vi.mock('../codex/codex-account-session-index-heal', () => ({ + createCodexAccountStateDb: async () => false, + healCodexAccountSessionIndex: async () => ({ + outcome: 'up-to-date', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + }) +})) beforeEach(() => { vi.resetModules() @@ -212,6 +224,8 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { const siblingRollout = join('2026', '07', '21', 'rollout-2026-07-21T10-00-00-bbbb.jsonl') writeRollout(systemHome(), systemRollout, '{"session":"real-home"}\n') writeRollout(accountOne.managedHomePath, siblingRollout, '{"session":"account-one"}\n') + // Why: history is linked only once Codex has indexed the new home (#20669). + writeCodexStateDbBackfillStatus(accountTwo.managedHomePath, 'complete') const { settings, store } = createStore([accountOne, accountTwo], accountOne.id) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const bridge = await import('../codex/codex-account-session-bridge') diff --git a/src/main/codex/codex-account-session-bridge.test.ts b/src/main/codex/codex-account-session-bridge.test.ts index 7cebd37c1f4..3befa013473 100644 --- a/src/main/codex/codex-account-session-bridge.test.ts +++ b/src/main/codex/codex-account-session-bridge.test.ts @@ -1,14 +1,31 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { _internals, bridgeCodexSessionsIntoAccountHome, - startCodexAccountSessionBridgeInBackground + startCodexAccountSessionBridgeInBackground, + stopCodexAccountSessionBridges } from './codex-account-session-bridge' +import { writeCodexStateDbBackfillStatus } from './codex-state-db-test-fixture' +import SyncDatabase from '../sqlite/sync-database' let workspaceRoot: string +const healIndexStub = vi.fn(async () => ({ + outcome: 'up-to-date' as const, + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 +})) function writeRollout(homePath: string, relativePath: string, contents: string): string { const filePath = join(homePath, 'sessions', relativePath) @@ -21,12 +38,25 @@ function rolloutPath(homePath: string, relativePath: string): string { return join(homePath, 'sessions', relativePath) } -const ROLLOUT_A = join('2026', '07', '20', 'rollout-2026-07-20T10-00-00-aaaa.jsonl') -const ROLLOUT_B = join('2026', '07', '21', 'rollout-2026-07-21T10-00-00-bbbb.jsonl') +const THREAD_A = '019a0000-0000-7000-8000-00000000000a' +const THREAD_B = '019a0000-0000-7000-8000-00000000000b' +const ROLLOUT_A = join( + '2026', + '07', + '20', + 'rollout-2026-07-20T10-00-00-019a0000-0000-7000-8000-00000000000a.jsonl' +) +const ROLLOUT_B = join( + '2026', + '07', + '21', + 'rollout-2026-07-21T10-00-00-019a0000-0000-7000-8000-00000000000b.jsonl' +) beforeEach(() => { workspaceRoot = mkdtempSync(join(tmpdir(), 'codex-account-session-bridge-')) _internals.resetBackgroundBridgeTasks() + healIndexStub.mockClear() }) afterEach(() => { @@ -47,7 +77,14 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { options: { batchSize: 1, yieldMs: 0 } }) - expect(summary).toEqual({ scannedFiles: 2, linkedFiles: 2 }) + expect(summary).toEqual({ + scannedFiles: 2, + linkedFiles: 2, + bridgedThreads: new Map([ + [THREAD_A, '2026-07-20T10-00-00'], + [THREAD_B, '2026-07-21T10-00-00'] + ]) + }) expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('system session\n') expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('account a session\n') }) @@ -97,7 +134,11 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [systemHome] }) - expect(second).toEqual({ scannedFiles: 1, linkedFiles: 0 }) + expect(second).toEqual({ + scannedFiles: 1, + linkedFiles: 0, + bridgedThreads: new Map([[THREAD_A, '2026-07-20T10-00-00']]) + }) }) it('never links a home into itself or scans a duplicate source twice', async () => { @@ -109,7 +150,27 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [targetHome, targetHome] }) - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0, bridgedThreads: new Map() }) + }) + + it('does not hand a rollout it failed to link to the index heal', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeRollout(systemHome, ROLLOUT_B, 'session\n') + // A file where the target's day directory belongs makes that one link fail. + mkdirSync(join(targetHome, 'sessions', '2026', '07'), { recursive: true }) + writeFileSync(join(targetHome, 'sessions', '2026', '07', '20'), 'not a directory') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await bridgeCodexSessionsIntoAccountHome({ + targetCodexHomePath: targetHome, + sourceCodexHomePaths: [systemHome] + }) + + warn.mockRestore() + expect(summary.linkedFiles).toBe(1) + expect(summary.bridgedThreads).toEqual(new Map([[THREAD_B, '2026-07-21T10-00-00']])) }) it('skips a source home that has no sessions tree', async () => { @@ -119,24 +180,35 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [join(workspaceRoot, 'missing')] }) - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0, bridgedThreads: new Map() }) }) }) +type BridgeDependencies = NonNullable< + Parameters[1] +> + +// Why: the real steps spawn `codex app-server`; their behavior is covered separately. +function startBridge( + targetCodexHomePath: string, + sourceCodexHomePaths: string[], + dependencies: BridgeDependencies = {} +): Promise { + return startCodexAccountSessionBridgeInBackground( + { targetCodexHomePath, sourceCodexHomePaths }, + { createStateDb: async () => false, healIndex: healIndexStub, ...dependencies } + ) +} + describe('startCodexAccountSessionBridgeInBackground', () => { it('shares one in-flight task per target home', () => { const systemHome = join(workspaceRoot, 'system') const targetHome = join(workspaceRoot, 'account') writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') - const first = startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: targetHome, - sourceCodexHomePaths: [systemHome] - }) - const second = startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: targetHome, - sourceCodexHomePaths: [systemHome] - }) + const first = startBridge(targetHome, [systemHome]) + const second = startBridge(targetHome, [systemHome]) expect(second).toBe(first) return first @@ -147,19 +219,203 @@ describe('startCodexAccountSessionBridgeInBackground', () => { const firstTarget = join(workspaceRoot, 'account-a') const secondTarget = join(workspaceRoot, 'account-b') writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(firstTarget, 'complete') + writeCodexStateDbBackfillStatus(secondTarget, 'complete') await Promise.all([ - startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: firstTarget, - sourceCodexHomePaths: [systemHome] - }), - startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: secondTarget, - sourceCodexHomePaths: [systemHome] - }) + startBridge(firstTarget, [systemHome]), + startBridge(secondTarget, [systemHome]) ]) expect(readFileSync(rolloutPath(firstTarget, ROLLOUT_A), 'utf-8')).toBe('session\n') expect(readFileSync(rolloutPath(secondTarget, ROLLOUT_A), 'utf-8')).toBe('session\n') }) + + // #20669: rollouts present when Codex first creates its state DB force a + // blocking backfill that times out the first TUI launch. + it('lets Codex index a new home before linking history into it', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + const createStateDb = vi.fn(async (home: string) => { + expect(existsSync(rolloutPath(home, ROLLOUT_A))).toBe(false) + writeCodexStateDbBackfillStatus(home, 'complete') + return true + }) + + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(createStateDb).toHaveBeenCalledWith(targetHome) + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('session\n') + }) + + // Why: an older Codex keeps no state DB, and `sqlite_home` keeps it outside + // the home; either way every launch must keep linking new history. + it('keeps linking new history on later launches when no state DB lives in the home', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'first\n') + const createStateDb = vi.fn(async () => true) + + await startBridge(targetHome, [systemHome], { createStateDb }) + writeRollout(systemHome, ROLLOUT_B, 'second\n') + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('first\n') + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('second\n') + // Only the empty home needed Codex to run first. + expect(createStateDb).toHaveBeenCalledTimes(1) + }) + + it('does not start Codex on an empty home when no source has history', async () => { + const createStateDb = vi.fn(async () => true) + + await startBridge(join(workspaceRoot, 'account'), [join(workspaceRoot, 'system')], { + createStateDb + }) + + expect(createStateDb).not.toHaveBeenCalled() + }) + + it('does not link history when Codex could not create the state DB', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + + await startBridge(targetHome, [systemHome], { createStateDb: async () => false }) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('bridges compressed-only history into a new home and indexes it', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + const compressed = `${ROLLOUT_A}.zst` + writeRollout(systemHome, compressed, 'compressed\n') + const createStateDb = vi.fn(async (home: string) => { + writeCodexStateDbBackfillStatus(home, 'complete') + return true + }) + + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(readFileSync(rolloutPath(targetHome, compressed), 'utf-8')).toBe('compressed\n') + expect(healIndexStub).toHaveBeenCalledWith( + targetHome, + new Map([[THREAD_A, '2026-07-20T10-00-00']]), + { + shouldStop: expect.any(Function) + } + ) + }) + + it('skips linking into a state DB that predates backfill tracking', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + mkdirSync(targetHome, { recursive: true }) + new SyncDatabase(join(targetHome, 'state_5.sqlite')).close() + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + }) + + it('skips linking while Codex is still indexing the home', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'running') + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('reports why it skips a home whose state DB cannot be read', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + mkdirSync(targetHome, { recursive: true }) + writeFileSync(join(targetHome, 'state_5.sqlite'), 'not a sqlite database') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Codex state DB is unreadable'), + expect.any(String) + ) + warn.mockRestore() + }) + + it('indexes the bridged threads once linking finishes', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') + + await startBridge(targetHome, [systemHome]) + + expect(healIndexStub).toHaveBeenCalledWith( + targetHome, + new Map([[THREAD_A, '2026-07-20T10-00-00']]), + { + shouldStop: expect.any(Function) + } + ) + }) + + it('links nothing when the app quits while Codex creates the state DB', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + + await startBridge(targetHome, [systemHome], { + createStateDb: async (home) => { + writeCodexStateDbBackfillStatus(home, 'complete') + stopCodexAccountSessionBridges() + return true + } + }) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('does not start Codex on a new home after the app quits', async () => { + const systemHome = join(workspaceRoot, 'system') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + const createStateDb = vi.fn(async () => true) + stopCodexAccountSessionBridges() + + await startBridge(join(workspaceRoot, 'account'), [systemHome], { createStateDb }) + + expect(createStateDb).not.toHaveBeenCalled() + }) + + it('stops indexing and starts no new bridge once the app quits', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + const laterTarget = join(workspaceRoot, 'later-account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') + writeCodexStateDbBackfillStatus(laterTarget, 'complete') + let shouldStop: (() => boolean) | undefined + + await startBridge(targetHome, [systemHome], { + healIndex: async (_home, _threads, options) => { + shouldStop = options?.shouldStop + stopCodexAccountSessionBridges() + return { outcome: 'stopped', healedThreads: 0, missingThreads: 0, failedThreads: 0 } + } + }) + await startBridge(laterTarget, [systemHome]) + + expect(shouldStop?.()).toBe(true) + expect(existsSync(rolloutPath(laterTarget, ROLLOUT_A))).toBe(false) + }) }) diff --git a/src/main/codex/codex-account-session-bridge.ts b/src/main/codex/codex-account-session-bridge.ts index 72096103aa1..bb6110a8651 100644 --- a/src/main/codex/codex-account-session-bridge.ts +++ b/src/main/codex/codex-account-session-bridge.ts @@ -3,7 +3,17 @@ import { dirname, join, relative } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' +import { + createCodexAccountStateDb, + healCodexAccountSessionIndex +} from './codex-account-session-index-heal' +import { parseCodexRolloutThreadId } from './codex-session-index-heal-state' import { linkCodexSessionFile } from './codex-session-link' +import { + countCodexSessionFilesUpTo, + findNewestCodexStateDbPath, + readCodexStateDbBackfillStatus +} from './codex-state-db' /** * Bridges Codex history between Orca-managed Codex homes. @@ -19,24 +29,99 @@ import { linkCodexSessionFile } from './codex-session-link' export type CodexAccountSessionBridgeSummary = { scannedFiles: number linkedFiles: number + /** Thread id -> rollout timestamp for every rollout present in the target home via this bridge. */ + bridgedThreads: Map } const backgroundBridgeTasksByTargetHome = new Map>() +let stopping = false + +type BackgroundBridgeDependencies = { + createStateDb: typeof createCodexAccountStateDb + healIndex: typeof healCodexAccountSessionIndex +} + +const defaultBackgroundBridgeDependencies: BackgroundBridgeDependencies = { + createStateDb: createCodexAccountStateDb, + healIndex: healCodexAccountSessionIndex +} + +/** + * Why: Codex indexes every rollout present when it first creates a home's state + * DB, and the TUI gives up waiting after 30s. Linking history into a fresh home + * first turns its first launch into a minutes-long backfill (#20669), so an + * empty home gets its state DB before any history; the heal indexes it afterwards. + */ +async function isReadyForBridgedHistory( + targetCodexHomePath: string, + sourceCodexHomePaths: readonly string[], + dependencies: BackgroundBridgeDependencies +): Promise { + if ( + !findNewestCodexStateDbPath(targetCodexHomePath) && + !hasSessionRollouts(targetCodexHomePath) + ) { + if ( + !sourceCodexHomePaths.some(hasSessionRollouts) || + !(await dependencies.createStateDb(targetCodexHomePath)) + ) { + return false + } + } + const status = readCodexStateDbBackfillStatus(targetCodexHomePath) + if (status.kind === 'unreadable') { + // Why: contention clears by the next launch; corruption would skip every launch, so surface it. + console.warn( + '[codex-account-session-bridge] Skipping history bridge; Codex state DB is unreadable:', + status.error + ) + return false + } + // Why: no DB in a home that has history means Codex keeps it elsewhere + // (`sqlite_home`) or keeps none, so linking cannot stall a launch here. + // `not-tracked` is a pre-backfill schema that Codex migrates to `pending`. + return status.kind === 'complete' || status.kind === 'missing' +} + +function hasSessionRollouts(codexHomePath: string): boolean { + return countCodexSessionFilesUpTo(join(codexHomePath, 'sessions'), 1) > 0 +} /** * Starts one background bridge per target home, sharing in-flight work. */ -export function startCodexAccountSessionBridgeInBackground(args: { - targetCodexHomePath: string - sourceCodexHomePaths: readonly string[] - options?: CodexSessionBridgeIncrementalOptions -}): Promise { +export function startCodexAccountSessionBridgeInBackground( + args: { + targetCodexHomePath: string + sourceCodexHomePaths: readonly string[] + options?: CodexSessionBridgeIncrementalOptions + }, + dependenciesOverride: Partial = {} +): Promise { + if (stopping) { + return Promise.resolve() + } const key = normalizeRuntimePathForComparison(args.targetCodexHomePath) const inFlight = backgroundBridgeTasksByTargetHome.get(key) if (inFlight) { return inFlight } - const task = bridgeCodexSessionsIntoAccountHome(args) + const dependencies = { ...defaultBackgroundBridgeDependencies, ...dependenciesOverride } + const task = isReadyForBridgedHistory( + args.targetCodexHomePath, + args.sourceCodexHomePaths, + dependencies + ) + .then(async (ready) => { + // Why: skip rather than feed a running backfill; the next launch retries. + if (!ready || stopping) { + return + } + const summary = await bridgeCodexSessionsIntoAccountHome(args) + await dependencies.healIndex(args.targetCodexHomePath, summary.bridgedThreads, { + shouldStop: () => stopping + }) + }) .catch((error: unknown) => { console.warn('[codex-account-session-bridge] Background session bridge failed:', error) }) @@ -50,6 +135,11 @@ export function startCodexAccountSessionBridgeInBackground(args: { return task } +/** Stops background bridges at quit; links and index reads made so far are kept. */ +export function stopCodexAccountSessionBridges(): void { + stopping = true +} + /** * Mirrors every source home's rollouts into the target home's sessions tree. */ @@ -58,7 +148,11 @@ export async function bridgeCodexSessionsIntoAccountHome(args: { sourceCodexHomePaths: readonly string[] options?: CodexSessionBridgeIncrementalOptions }): Promise { - const summary: CodexAccountSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } + const summary: CodexAccountSessionBridgeSummary = { + scannedFiles: 0, + linkedFiles: 0, + bridgedThreads: new Map() + } const targetSessionsRoot = join(args.targetCodexHomePath, 'sessions') for (const sourceHomePath of dedupeSourceHomes( args.sourceCodexHomePaths, @@ -73,9 +167,18 @@ export async function bridgeCodexSessionsIntoAccountHome(args: { args.options ?? {} )) { summary.scannedFiles += 1 - if (bridgeRolloutIntoAccountHome(sourceSessionsRoot, targetSessionsRoot, sourceFilePath)) { + const result = bridgeRolloutIntoAccountHome( + sourceSessionsRoot, + targetSessionsRoot, + sourceFilePath + ) + if (result === 'linked') { summary.linkedFiles += 1 } + const rollout = parseCodexRolloutThreadId(sourceFilePath) + if (result !== 'failed' && rollout) { + summary.bridgedThreads.set(rollout.threadId, rollout.rolloutStamp) + } } } return summary @@ -88,20 +191,20 @@ function bridgeRolloutIntoAccountHome( sourceSessionsRoot: string, targetSessionsRoot: string, sourceFilePath: string -): boolean { +): 'linked' | 'existing' | 'failed' { const targetFilePath = join(targetSessionsRoot, relative(sourceSessionsRoot, sourceFilePath)) // Why: rollout names carry the session UUID, so an existing target path is the // same conversation already bridged (often the same inode) — never a conflict. if (existsSync(targetFilePath)) { - return false + return 'existing' } try { mkdirSync(dirname(targetFilePath), { recursive: true }) } catch (error) { console.warn('[codex-account-session-bridge] Failed to create session directory:', error) - return false + return 'failed' } - return linkCodexSessionFile(sourceFilePath, targetFilePath) + return linkCodexSessionFile(sourceFilePath, targetFilePath) ? 'linked' : 'failed' } /** @@ -127,5 +230,6 @@ function dedupeSourceHomes( export const _internals = { resetBackgroundBridgeTasks: (): void => { backgroundBridgeTasksByTargetHome.clear() + stopping = false } } diff --git a/src/main/codex/codex-account-session-index-heal.test.ts b/src/main/codex/codex-account-session-index-heal.test.ts new file mode 100644 index 00000000000..fbb5fae404c --- /dev/null +++ b/src/main/codex/codex-account-session-index-heal.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + _internals, + createCodexAccountStateDb, + healCodexAccountSessionIndex +} from './codex-account-session-index-heal' +import { + CodexAppServerUnsupportedError, + type CodexAppServerInvocation, + type CodexAppServerRpc +} from './codex-app-server-session' + +const HOME = '/codex-accounts/account-1/home' + +// Why: fails closed if a regression bypasses the fake session — never the real codex or ~/.codex. +function buildInvocation(): CodexAppServerInvocation { + return { + command: '/nonexistent/orca-test-codex', + args: ['app-server'], + cliPath: null, + env: { CODEX_HOME: join(tmpdir(), 'orca-test-nonexistent-codex-home') }, + timeoutMs: 1_000 + } +} + +/** Runs the heal body against a fake app-server that answers thread/read. */ +function fakeAppServer(onRead: (threadId: string) => void = () => {}) { + const readThreadIds: string[] = [] + const runSession = vi.fn( + async ( + _invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ): Promise => { + await body({ + request: async (method, params) => { + expect(method).toBe('thread/read') + const threadId = String(params?.threadId) + readThreadIds.push(threadId) + onRead(threadId) + return {} + }, + notify: () => {} + }) + } + ) + return { runSession, readThreadIds } +} + +/** Bridged threads keyed by id, all from the same rollout timestamp. */ +function bridged(...threadIds: string[]): Map { + return new Map(threadIds.map((threadId) => [threadId, '2026-07-20T10-00-00'])) +} + +beforeEach(() => { + _internals.resetFailedThreads() +}) + +describe('healCodexAccountSessionIndex', () => { + it('reads only bridged threads missing from the Codex index', async () => { + const { runSession, readThreadIds } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a', 'b', 'c'), { + readIndexedThreadIds: () => new Set(['b']), + buildInvocation, + runSession + }) + + expect(readThreadIds.sort()).toEqual(['a', 'c']) + expect(summary).toEqual({ + outcome: 'completed', + healedThreads: 2, + missingThreads: 0, + failedThreads: 0 + }) + }) + + it('reads the most recent bridged rollouts first', async () => { + const { runSession, readThreadIds } = fakeAppServer() + + await healCodexAccountSessionIndex( + HOME, + new Map([ + ['middle', '2026-08-01T09-00-00'], + ['oldest', '2025-12-31T23-59-59'], + ['newest', '2026-09-28T01-46-16'] + ]), + { readIndexedThreadIds: () => new Set(), buildInvocation, runSession, readConcurrency: 1 } + ) + + expect(readThreadIds).toEqual(['newest', 'middle', 'oldest']) + }) + + it('does not start Codex when every bridged thread is already indexed', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(['a']), + buildInvocation, + runSession + }) + + expect(summary.outcome).toBe('up-to-date') + expect(runSession).not.toHaveBeenCalled() + }) + + it('does not start Codex when its index cannot be read', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => null, + buildInvocation, + runSession + }) + + expect(summary.outcome).toBe('no-index') + expect(runSession).not.toHaveBeenCalled() + }) + + it('does not start Codex once the app is quitting', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession, + shouldStop: () => true + }) + + expect(summary.outcome).toBe('stopped') + expect(runSession).not.toHaveBeenCalled() + }) + + it('stops retrying a thread Codex refuses to index until Orca restarts', async () => { + const { runSession, readThreadIds } = fakeAppServer((threadId) => { + if (threadId === 'broken') { + throw new Error('codex app-server thread/read failed: invalid rollout') + } + }) + const dependencies = { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + } + + const first = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies) + const second = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies) + + expect(first).toEqual({ + outcome: 'completed', + healedThreads: 0, + missingThreads: 0, + failedThreads: 1 + }) + expect(second.outcome).toBe('up-to-date') + expect(readThreadIds).toEqual(['broken']) + }) + + it('remembers a refused thread only for the home that refused it', async () => { + const { runSession, readThreadIds } = fakeAppServer((threadId) => { + if (readThreadIds.length === 1) { + throw new Error(`codex app-server thread/read failed: invalid rollout ${threadId}`) + } + }) + const dependencies = { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + } + + await healCodexAccountSessionIndex(HOME, bridged('shared'), dependencies) + const other = await healCodexAccountSessionIndex( + '/codex-accounts/account-2/home', + bridged('shared'), + dependencies + ) + + expect(other.healedThreads).toBe(1) + expect(readThreadIds).toEqual(['shared', 'shared']) + }) + + it('counts a rollout Codex cannot find as missing and does not reread it', async () => { + const { runSession, readThreadIds } = fakeAppServer(() => { + throw new Error('codex app-server thread/read failed: no rollout found for thread id gone') + }) + const options = { readIndexedThreadIds: () => new Set(), buildInvocation, runSession } + + const first = await healCodexAccountSessionIndex(HOME, bridged('gone'), options) + const second = await healCodexAccountSessionIndex(HOME, bridged('gone'), options) + + expect(first).toEqual({ + outcome: 'completed', + healedThreads: 0, + missingThreads: 1, + failedThreads: 0 + }) + expect(second.outcome).toBe('up-to-date') + expect(readThreadIds).toEqual(['gone']) + }) + + it('retries a thread on the next pass when the app-server session fails', async () => { + const failing = vi.fn(async () => { + throw new Error('codex app-server exited before responding') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const readIndexedThreadIds = (): Set => new Set() + + const first = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds, + buildInvocation, + runSession: failing + }) + const { runSession, readThreadIds } = fakeAppServer() + const second = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds, + buildInvocation, + runSession + }) + + expect(first.outcome).toBe('aborted') + expect(second.outcome).toBe('completed') + expect(readThreadIds).toEqual(['a']) + warn.mockRestore() + }) + + it('reports a session that fails during quit as stopped, not unsupported', async () => { + let stopping = false + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession: async () => { + stopping = true + throw new CodexAppServerUnsupportedError('app-server killed during quit') + }, + shouldStop: () => stopping + }) + + expect(summary.outcome).toBe('stopped') + }) + + it('aborts rather than writing off a thread while a live Codex holds the database', async () => { + const { runSession } = fakeAppServer(() => { + throw new Error('codex app-server thread/read failed: database is locked') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + }) + + expect(summary).toEqual({ + outcome: 'aborted', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + }) + warn.mockRestore() + }) +}) + +describe('createCodexAccountStateDb', () => { + it('starts Codex on the home without sending any request', async () => { + const { runSession, readThreadIds } = fakeAppServer() + const invocations: string[] = [] + + const created = await createCodexAccountStateDb(HOME, { + buildInvocation: (home, timeoutMs) => { + invocations.push(home) + return { ...buildInvocation(), timeoutMs } + }, + runSession + }) + + expect(created).toBe(true) + expect(invocations).toEqual([HOME]) + expect(readThreadIds).toEqual([]) + }) + + it('treats a Codex without app-server as having no state DB to stall', async () => { + const created = await createCodexAccountStateDb(HOME, { + buildInvocation, + runSession: async () => { + throw new CodexAppServerUnsupportedError('unknown subcommand app-server') + } + }) + + expect(created).toBe(true) + }) + + it('reports failure when Codex could not start', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const created = await createCodexAccountStateDb(HOME, { + buildInvocation, + runSession: async () => { + throw new Error('spawn codex ENOENT') + } + }) + + expect(created).toBe(false) + warn.mockRestore() + }) +}) diff --git a/src/main/codex/codex-account-session-index-heal.ts b/src/main/codex/codex-account-session-index-heal.ts new file mode 100644 index 00000000000..1af87e34407 --- /dev/null +++ b/src/main/codex/codex-account-session-index-heal.ts @@ -0,0 +1,122 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + isCodexAppServerUnsupportedError, + runCodexAppServerSession +} from './codex-app-server-session' +import { + buildNativeHealInvocation, + readCodexThreadsForIndexHeal, + type CodexSessionIndexHealOptions, + type CodexThreadReadPassOutcome +} from './codex-session-index-heal' +import { readIndexedCodexThreadIds } from './codex-state-db' + +// Why: Codex indexes a home's rollouts only once, when it first creates the +// state DB, and the /resume picker's "All" view lists only indexed threads. +// History bridged in afterwards needs `thread/read`, Codex's lazy-indexing +// path, to become visible there (#20669). + +export type CodexAccountSessionIndexHealSummary = { + outcome: CodexThreadReadPassOutcome | 'up-to-date' | 'no-index' + healedThreads: number + missingThreads: number + failedThreads: number +} + +export type CodexAccountSessionIndexHealOptions = CodexSessionIndexHealOptions & { + readIndexedThreadIds?: (codexHomePath: string) => Set | null +} + +// Why: app-server finishes state DB startup before it answers `initialize`, +// which takes ~100ms on an empty home. +const STATE_DB_CREATE_TIMEOUT_MS = 15_000 + +// Why: a just-linked rollout Codex refuses or cannot find fails the same way on +// every read; skip it until Orca restarts instead of respawning app-server each launch. +const failedThreadIdsByHome = new Map>() + +/** + * Starts Codex once on an empty home so it creates and indexes its state DB + * before any history is bridged in. False when Codex could not start. + */ +export async function createCodexAccountStateDb( + codexHomePath: string, + options: Pick = {} +): Promise { + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const runSession = options.runSession ?? runCodexAppServerSession + try { + await runSession(buildInvocation(codexHomePath, STATE_DB_CREATE_TIMEOUT_MS), async () => {}) + return true + } catch (error) { + // Why: a Codex without app-server predates the state DB, so linking cannot stall it. + if (isCodexAppServerUnsupportedError(error)) { + return true + } + console.warn('[codex-account-session-index-heal] Failed to create Codex state DB:', error) + return false + } +} + +/** + * Indexes the bridged threads Codex has not indexed yet, newest rollout first. + * Diffing against the state DB on every pass makes an interrupted heal resume + * on the next launch. + */ +export async function healCodexAccountSessionIndex( + codexHomePath: string, + bridgedThreads: ReadonlyMap, + options: CodexAccountSessionIndexHealOptions = {} +): Promise { + const summary: CodexAccountSessionIndexHealSummary = { + outcome: 'up-to-date', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + } + if (bridgedThreads.size === 0) { + return summary + } + // Why: with no DB in the home, Codex keeps none (older CLI) or uses a + // `sqlite_home` shared by every Orca home, which already indexes these threads. + const indexed = (options.readIndexedThreadIds ?? readIndexedCodexThreadIds)(codexHomePath) + if (!indexed) { + return { ...summary, outcome: 'no-index' } + } + const homeKey = normalizeRuntimePathForComparison(codexHomePath) + const failed = failedThreadIdsByHome.get(homeKey) ?? new Set() + failedThreadIdsByHome.set(homeKey, failed) + // Why: a large history takes minutes to index, and /resume hides unindexed + // threads once a directory has any indexed one, so recent work goes first. + const pending = [...bridgedThreads] + .filter(([threadId]) => !indexed.has(threadId) && !failed.has(threadId)) + .sort(([, left], [, right]) => (left < right ? 1 : left > right ? -1 : 0)) + .map(([threadId]) => ({ threadId })) + if (pending.length === 0) { + return summary + } + summary.outcome = await readCodexThreadsForIndexHeal( + codexHomePath, + pending, + ({ threadId }, outcome) => { + if (outcome === 'healed') { + summary.healedThreads += 1 + return + } + failed.add(threadId) + if (outcome === 'missing') { + summary.missingThreads += 1 + } else { + summary.failedThreads += 1 + } + }, + options + ) + return summary +} + +export const _internals = { + resetFailedThreads: (): void => { + failedThreadIdsByHome.clear() + } +} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index a4ebd72cf01..3d95e7ce380 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -21,6 +21,7 @@ const originalCodexHome = process.env.CODEX_HOME afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() if (originalCodexHome === undefined) { delete process.env.CODEX_HOME } else { @@ -340,13 +341,42 @@ describe('openCodexAppServerConnection', () => { ) child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`) - child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`) + child.stdout.write(`${JSON.stringify({ id: null, error: { message: 'parse error' } })}\n`) await vi.waitFor(() => expect(frames).toHaveLength(2)) - expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched']) + expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'frame:unclassified']) await connection.close() }) + it('logs a reply to a timed-out request instead of surfacing it as a frame', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + const frames: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onUnhandledFrame: (kind) => frames.push(kind) }, + spawnImpl + ) + + const slow = rejection(connection.request('turn/interrupt', undefined, { timeoutMs: 50 })) + await vi.advanceTimersByTimeAsync(60) + expect((await slow).name).toBe('CodexAppServerTimeoutError') + const id = Number(written.find((frame) => frame.method === 'turn/interrupt')?.id) + child.stdout.write(`${JSON.stringify({ id, result: {} })}\n`) + child.stdout.write(`${JSON.stringify({ id: 999, error: { message: 'no such request' } })}\n`) + await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(2)) + + expect(frames).toEqual([]) + expect(warn.mock.calls.map((call) => call[0])).toEqual([ + `[codex-app-server] late reply to turn/interrupt after timeout (id ${id})`, + '[codex-app-server] reply with no waiting request (id 999)' + ]) + expect(warn.mock.calls[1][1]).toBe('no such request') + await vi.advanceTimersByTimeAsync(0) + }) + it('fails in-flight requests and reports an unexpected exit once', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 5bed3b594d7..3e7d16ea1bb 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -213,7 +213,7 @@ export async function openCodexAppServerConnection( // Why: per request, not per session — a chat session outlives every call, // so only the individual call can carry a deadline. const timer = setTimeout(() => { - dispatcher.deletePending(id) + dispatcher.timeOutPending(id) reject(new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`)) }, timeoutMs) dispatcher.addPending(id, { method, resolve, reject, timer }) diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/codex/codex-app-server-posix-supervisor.test.ts index 6dbe03e5b2a..99cbaf10008 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.test.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.test.ts @@ -43,14 +43,9 @@ describe('structured provider supervision', () => { ) expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('delete childEnv.ELECTRON_RUN_AS_NODE') expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('process.ppid !== spec.ownerPid') expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain( "process.stdin.once('close', scheduleOwnerShutdown)" ) - expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('detached: true') - expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain("process.kill(-child.pid, 'SIGKILL')") - expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('providerGroupExists()') - expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('finishWithProviderOutcome(code, signal)') expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).not.toContain('process.ppid === 1') }) diff --git a/src/main/codex/codex-app-server-process-tree-kill.ts b/src/main/codex/codex-app-server-process-tree-kill.ts index 315246aaa9f..1d8d0f10bda 100644 --- a/src/main/codex/codex-app-server-process-tree-kill.ts +++ b/src/main/codex/codex-app-server-process-tree-kill.ts @@ -32,8 +32,8 @@ export function killCodexAppServerProcessTree( return } try { - // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper - // leaves the app-server child alive after a timeout or failed shutdown. + // Why: npm-installed Codex runs behind a launcher (node, or cmd.exe for an unresolved shim); + // killing only that wrapper leaves the app-server child alive after a timeout. const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index b1d3605b38f..cbac80f3643 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -10,6 +10,7 @@ import { import { classifyJsonRpcPrefix } from './codex-app-server-record-prefix' const OVERSIZED_REQUEST_ERROR_CODE = -32001 +const MAX_REMEMBERED_TIMEOUTS = 64 export type CodexPendingRequest = { method: string @@ -25,11 +26,26 @@ export function createCodexAppServerRecordDispatcher(input: { }): { addPending: (id: number, waiter: CodexPendingRequest) => void deletePending: (id: number) => void + timeOutPending: (id: number) => void failPending: (error: Error) => void dispatch: (message: Record) => void rejectOversized: (rejected: NdjsonRejectedRecord & { kind: 'line-too-long' }) => void } { const pending = new Map() + const timedOutMethods = new Map() + + const timeOutPending = (id: number): void => { + const waiter = pending.get(id) + if (!waiter) { + return + } + pending.delete(id) + timedOutMethods.set(id, waiter.method) + const oldest = timedOutMethods.keys().next() + if (timedOutMethods.size > MAX_REMEMBERED_TIMEOUTS && !oldest.done) { + timedOutMethods.delete(oldest.value) + } + } const failPending = (error: Error): void => { for (const waiter of pending.values()) { @@ -72,7 +88,17 @@ export function createCodexAppServerRecordDispatcher(input: { } const waiter = pending.get(message.id) if (!waiter) { - input.handlers.onUnhandledFrame?.('response:unmatched', message) + // Transport diagnostics, not conversation: only the request that gave up could have + // interpreted this reply, and it already reported its own outcome. + const timedOutMethod = timedOutMethods.get(message.id) + timedOutMethods.delete(message.id) + const error = isAppServerRecord(message.error) ? message.error.message : undefined + console.warn( + timedOutMethod + ? `[codex-app-server] late reply to ${timedOutMethod} after timeout (id ${message.id})` + : `[codex-app-server] reply with no waiting request (id ${message.id})`, + ...(typeof error === 'string' ? [error] : []) + ) return } pending.delete(message.id) @@ -160,6 +186,7 @@ export function createCodexAppServerRecordDispatcher(input: { return { addPending: (id, waiter) => pending.set(id, waiter), deletePending: (id) => pending.delete(id), + timeOutPending, failPending, dispatch, rejectOversized diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 067fdd6d6b7..389cd20516c 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -25,7 +25,7 @@ export type CodexAppServerInvocation = { * * Required, and `null` only for a guest-side launcher (wsl.exe) where the host * path means nothing. Optional would let a native builder omit it and silently - * fall back to pairing against a cmd.exe wrapper with no type error. + * skip the pairing with no type error. */ cliPath: string | null /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 029cc7b50c7..f01863956b6 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -7,7 +7,7 @@ import { import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' -import { readCodexErrorWillRetry, readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexTurnId } from './codex-structured-thread-facts' export type CodexBackgroundTaskFrame = | { @@ -25,15 +25,11 @@ export type CodexBackgroundTaskFrame = state: NativeChatSubagentState } | { - /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */ - kind: 'turn-ended' + /** A child thread that closed: it ran its last turn, and Codex never said how it went. */ + kind: 'thread-closed' threadId: string - turnId: string | null - state: CodexChildTurnEnding } -type CodexChildTurnEnding = Extract - export type CodexBackgroundTaskEvent = { method: string threadId: string @@ -41,24 +37,16 @@ export type CodexBackgroundTaskEvent = { } /** - * The two ways Codex ends a child's turn without `turn/completed`. An `error` it will not retry is - * that turn's own end: for a child, `turn/completed` may never follow, and without this end the - * child's lifecycle row latches on `working` for the life of the session. A closed thread ran its - * last turn, and Codex never said how it went. - * - * #23682 dropped the equivalent reading from the primary journal path, where Codex's own failed - * `turn/completed` always follows within ~32 ms and carries the duration the error lacks. A child - * has no such guarantee, which is why this path still settles on the error. + * The one way Codex ends a child's turn without `turn/completed`: a closed thread ran its last + * turn and Codex never said how it went. A turn-ending `error` is not one — Codex follows it with + * a failed `turn/completed` for the same turn, which is that turn's end and carries the duration + * and receipt time the error does not. */ -function readCodexChildTurnEnding( +function readCodexChildThreadClosed( event: CodexBackgroundTaskEvent ): CodexBackgroundTaskFrame | null { - if (event.method === 'error' && !readCodexErrorWillRetry(event.params)) { - const turnId = readCodexTurnId(event.params) - return { kind: 'turn-ended', threadId: event.threadId, turnId, state: 'failed' } - } return event.method === 'thread/closed' - ? { kind: 'turn-ended', threadId: event.threadId, turnId: null, state: 'unverifiable' } + ? { kind: 'thread-closed', threadId: event.threadId } : null } @@ -67,7 +55,7 @@ export function readCodexBackgroundTaskFrame( primaryThreadId: string ): CodexBackgroundTaskFrame | null { // The session's own turn ends through the journal's turn boundaries, never here. - const ending = event.threadId === primaryThreadId ? null : readCodexChildTurnEnding(event) + const ending = event.threadId === primaryThreadId ? null : readCodexChildThreadClosed(event) if (ending) { return ending } diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 351b35e6b84..6429a84d4fd 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -80,8 +80,8 @@ export class CodexBackgroundTaskTracker { frame.parentTurnId, frame.spawnerThreadId ) - } else if (frame?.kind === 'turn-ended') { - this.executions.endTurn(frame.threadId, frame.turnId, frame.state) + } else if (frame?.kind === 'thread-closed') { + this.executions.closeThread(frame.threadId) } else if (frame && frame.threadId !== this.primaryThreadId) { this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) } diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 9013b07a674..5b1b1bc29b4 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -159,18 +159,10 @@ describe('Codex child-work evidence', () => { expect(tracker.state).toBeNull() }) - const childError = ( - turnId: string | undefined, - willRetry: boolean - ): CodexBackgroundTaskEvent => ({ + const childError = (turnId: string, willRetry: boolean): CodexBackgroundTaskEvent => ({ method: 'error', threadId: CHILD, - params: { - threadId: CHILD, - ...(turnId ? { turnId } : {}), - willRetry, - error: { message: 'boom' } - } + params: { threadId: CHILD, turnId, willRetry, error: { message: 'boom' } } }) const childClosed: CodexBackgroundTaskEvent = { method: 'thread/closed', @@ -178,25 +170,30 @@ describe('Codex child-work evidence', () => { params: { threadId: CHILD } } - it.each([ - ['an error naming its turn that Codex will not retry', childError('c1', false), 'failed'], - ['an error naming no turn that Codex will not retry', childError(undefined, false), 'failed'], - ['its thread closing', childClosed, 'unknown'] - ])( - 'settles a working child whose turn ended with no turn/completed, by %s, in the strip and the record together', - (_label, ending, outcome) => { - const { send, tracker, records } = runningChild() - expect(tracker.state?.tasks).toHaveLength(1) - send(ending) - expect(records()).toEqual([ - expect.objectContaining({ membership: 'settled', state: 'done', outcome }) - ]) - expect(tracker.state).toBeNull() - // The first ending a turn gets stands. - send(turn('turn/completed', CHILD, 'c1', 'completed')) - expect(records()).toEqual([expect.objectContaining({ outcome })]) - } - ) + it('keeps a child working through an error Codex will not retry, and settles it on the failed turn/completed that follows', () => { + const { send, tracker, records } = runningChild() + send(childError('c1', false)) + expect(records()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(tracker.state?.tasks).toHaveLength(1) + send(turn('turn/completed', CHILD, 'c1', 'failed')) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'failed' }) + ]) + expect(tracker.state).toBeNull() + }) + + it('settles a working child whose thread closed with no turn/completed, in the strip and the record together', () => { + const { send, tracker, records } = runningChild() + expect(tracker.state?.tasks).toHaveLength(1) + send(childClosed) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'unknown' }) + ]) + expect(tracker.state).toBeNull() + // The first ending a turn gets stands. + send(turn('turn/completed', CHILD, 'c1', 'completed')) + expect(records()).toEqual([expect.objectContaining({ outcome: 'unknown' })]) + }) it('keeps a child working through a retried error and a systemError status: its turn runs on', () => { const { send, tracker, records } = runningChild() @@ -207,9 +204,6 @@ describe('Codex child-work evidence', () => { }) expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) expect(tracker.state?.tasks).toHaveLength(1) - // A fatal error naming a turn the child already finished ends nothing. - send(turn('turn/completed', CHILD, 'c1'), childError('c1', false)) - expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) }) it('never settles a child on its PARENT turn ending: children outlive the turn', () => { diff --git a/src/main/codex/codex-command-turn-claim.test.ts b/src/main/codex/codex-command-turn-claim.test.ts new file mode 100644 index 00000000000..078060497f2 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -0,0 +1,375 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION = 'session-1' +const THREAD = 'thread-1' +const PROVIDER_TURN = 'compact-turn' +const COMMAND_TURN_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_TURN_KEY = agentJournalItemKey(COMMAND_TURN_IDENTITY) +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 1, + startedAt: 1 +} +const COMMAND = { + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_TURN_IDENTITY, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'command-result:cmd-1' }, + running: RUNNING +} +const ACCEPTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +type Written = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** Records every write with the scope it states; the journal holds the running command turn. */ +function recorder(refuseRevisions = 0) { + const writes: Written[] = [] + const record = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => writes.push({ key: agentJournalItemKey(identity), body, turnScope }) + const commandTurn: AgentJournalItemBody = RUNNING + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claim resolver reads only `itemBody`. + const journal = { + itemBody: (itemId: string) => (itemId === COMMAND_TURN_KEY ? commandTurn : null) + } as unknown as StructuredAgentSessionRevisionJournal + let refusals = refuseRevisions + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => record(identity, body, options.turnScope), + appendTombstone: () => {}, + publish: () => {}, + tryPublish: () => ACCEPTED, + tryAppendItem: (identity, body, options) => { + record(identity, body, options.turnScope) + return ACCEPTED + }, + appendLifecycleBatch: (_settlementId, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + record(mutation.identity, mutation.body, mutation.turnScope) + } + } + }, + tryReviseResolvedItemAndPublish: (_bytes, resolve, options) => { + if (refusals > 0) { + refusals -= 1 + return { accepted: false, reason: 'backpressure' } + } + const resolved = resolve(journal) + if (resolved) { + record(resolved.identity, resolved.body, options.turnScope) + } + return ACCEPTED + } + } + return { sink, writes } +} + +/** Codex names the thread inside every notification's params too. */ +function notification( + method: string, + params: Record +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, ...params } + } +} + +function harness(refuseRevisions = 0) { + const tap = recorder(refuseRevisions) + const translator = createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + return { + ...tap, + translator, + emit: (event: CodexStructuredSessionEvent) => translator.handle(event) + } +} + +const codexTurnRecords = (writes: readonly Written[]) => + writes.filter((write) => write.key !== COMMAND_TURN_KEY && readAgentJournalTurn(write.body)) + +/** The command turn's newest body, as the journal would hold it. */ +const commandTurn = (writes: readonly Written[]) => + readAgentJournalTurn(writes.findLast((write) => write.key === COMMAND_TURN_KEY)?.body) + +const resultRows = (writes: readonly Written[]) => + writes.filter((write) => write.body.kind === 'status') + +describe('a Codex turn a conversation command claims', () => { + it('is the command turn: writes no root turn, scopes its content there, and ends it', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + // The claim is persisted on the command turn: nothing else re-derives it later. + expect(commandTurn(writes)).toMatchObject({ state: 'running', providerTurnId: PROVIDER_TURN }) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'agentMessage', id: 'summary', text: 'Summary of the conversation.' } + }) + ) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(codexTurnRecords(writes)).toEqual([]) + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const summary = writes.find( + (write) => write.body.kind === 'message' && write.body.role === 'assistant' + ) + expect(summary?.turnScope).toEqual(scope) + // Codex's own marker is the command's result row. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: scope + }) + ]) + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'success', + providerTurnId: PROVIDER_TURN, + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt + }) + }) + + it('reads an interrupted turn as the command cancelled, with no result row', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'interrupted' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(resultRows(writes)).toEqual([]) + }) + + it('reads a turn that completed without compacting as a failure, with the reason', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Unavailable.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Unavailable', audience: 'person' } + }, + tone: 'error' + } + ]) + }) + + it('says only that the compaction failed when a turn that did not complete gave no words', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a turn that completed without Codex reporting a compaction as unconfirmed', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('ends a compaction Codex failed with an error once, however late its completion', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { + turnId: PROVIDER_TURN, + willRetry: false, + error: { message: 'Unavailable' } + }) + ) + // The error is a row; only the completion ends the turn. + expect(commandTurn(writes)).toMatchObject({ state: 'running' }) + // Codex still completes the turn it failed, as status failed. + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + writes.filter( + (write) => write.key === COMMAND_TURN_KEY && readAgentJournalTurn(write.body)?.completedAt + ) + ).toHaveLength(1) + // Codex's own error row, in the command's turn; the completion adds none. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ kind: 'status', text: 'Unavailable', tone: 'error' }), + turnScope: { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + }) + ]) + }) + + it("ends a captured failed compaction on its completion, below one row of Codex's own", () => { + // A real app-server's compaction turn: two retried stream errors, the turn-ending error, then + // the failed completion. + const captured: { case: string; t: number; method: string; params: object }[] = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + .filter((frame) => frame.case === '0.158.0-compact-overloaded') + const compactionTurn = captured.findLastIndex((frame) => frame.method === 'turn/started') + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + for (const frame of captured.slice(compactionTurn)) { + emit({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method: frame.method, + params: { ...frame.params, threadId: THREAD }, + observedAt: frame.t + }) + } + + expect(codexTurnRecords(writes)).toEqual([]) + const completion = captured.at(-1)! + expect(completion.method).toBe('turn/completed') + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'failure', + completedAt: completion.t + }) + // Every row is one of Codex's frames, inside the command's turn; the completion adds none. + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const rows = resultRows(writes) + expect(rows.map((write) => write.key)).not.toContain( + agentJournalItemKey(COMMAND.resultIdentity) + ) + expect(rows.map((write) => write.turnScope)).toEqual(rows.map(() => scope)) + expect( + rows.some( + (write) => + write.body.kind === 'status' && + write.body.tone === 'error' && + write.body.text.includes('Selected model is at capacity') + ) + ).toBe(true) + }) + + it('leaves the next turn to write its own record after a failed compaction', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { turnId: PROVIDER_TURN, willRetry: false, error: { message: 'x' } }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.turnId) + ).toEqual(['ordinary', 'ordinary']) + }) + + it('names no provider turn for a Stop until Codex opens one, then the one it opened', () => { + const { translator, emit } = harness() + translator.beginCommand(COMMAND) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBeUndefined() + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + expect(translator.commandProviderTurnId('ordinary')).toBe('ordinary') + }) + + it('claims the same provider turn when the refused start is retried', () => { + const { writes, translator, emit } = harness(1) + translator.beginCommand(COMMAND) + const started = notification('turn/started', { turn: { id: PROVIDER_TURN } }) + + expect(emit(started)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(emit(started)).toEqual(ACCEPTED) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ providerTurnId: PROVIDER_TURN }) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + }) + + it('leaves a primary turn no command claims to write its own record', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + translator.forgetCommand(COMMAND.turnId) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.state) + ).toEqual(['running', 'completed']) + }) +}) diff --git a/src/main/codex/codex-command-turn-claim.ts b/src/main/codex/codex-command-turn-claim.ts new file mode 100644 index 00000000000..e1278a8b5c4 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.ts @@ -0,0 +1,32 @@ +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +const CLAIM_BYTES = 4_096 + +/** Records on the command's turn which provider turn carried it out. Persisted because nothing + * else re-derives it once the command settles, and a rewind has to place that turn's rows. */ +export function recordCodexCommandTurnClaim( + sink: StructuredAgentSessionEventSink, + commandTurnItemId: string, + providerTurnId: string +): StructuredAgentSessionSinkAdmission { + const identity = parseAgentJournalItemKey(commandTurnItemId) + if (!identity || !sink.tryReviseResolvedItemAndPublish) { + return { accepted: true } + } + return sink.tryReviseResolvedItemAndPublish( + CLAIM_BYTES, + (journal) => { + const body = journal.itemBody(commandTurnItemId) + // Settled already — by the host, or by a child's death — leaves nothing to annotate. + return body?.kind === 'turn' && body.state === 'running' + ? { identity, body: { ...body, providerTurnId } } + : null + }, + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts index 85121996a06..2c8a86fd28b 100644 --- a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -92,38 +92,32 @@ describe('canonical MCP ownership during config mirroring', () => { }) describe('MCP ownership migration', () => { - it.each([1, 2, 3])( - 'keeps pre-ownership baseline version %s canonical for one pass', - (version) => { - writeFileSync( - join(runtimeHomePath, 'config.toml'), - '[mcp_servers.removed]\ncommand = "old"\n' - ) - writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') - writeFileSync( - join(runtimeHomePath, '.orca-config-settings-baseline.json'), - JSON.stringify({ version, settings: {} }) - ) + it('keeps a pre-ownership baseline canonical for one pass', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeFileSync( + join(runtimeHomePath, '.orca-config-settings-baseline.json'), + JSON.stringify({ version: 1, settings: {} }) + ) - syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) - expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( - '[mcp_servers.' + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + expect( + JSON.parse( + readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8') ) - expect( - JSON.parse( - readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8') - ) - ).toMatchObject({ mcpServers: [] }) - writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.added]\ncommand = "new"\n') + ).toMatchObject({ mcpServers: [] }) + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.added]\ncommand = "new"\n') - syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) - expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain( - '[mcp_servers.added]' - ) - } - ) + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.added]' + ) + }) it('keeps the retained shared home one-way without an ownership baseline', () => { writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') diff --git a/src/main/codex/codex-goal-journal-rows.test.ts b/src/main/codex/codex-goal-journal-rows.test.ts index dcbc11a816f..81597062441 100644 --- a/src/main/codex/codex-goal-journal-rows.test.ts +++ b/src/main/codex/codex-goal-journal-rows.test.ts @@ -113,15 +113,4 @@ describe('goal frames as journal rows', () => { expect(row?.body.text).toBe('Goal cleared') }) - - it('never shows the bare opcode, which is what a plain reclassify would have done', () => { - const row = unhandledProviderFrameJournalItem( - 'codex', - 'notification:thread/goal/updated', - goalFrame() - ) - - expect(row?.body.text).not.toContain('notification:') - expect(row?.body.text).not.toContain('codex · ') - }) }) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index b265245f92d..c76aee5c1ea 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -47,6 +47,14 @@ export function getOrcaManagedCodexHomePath(): string { return managedHomePath } +/** Config files an Orca-launched local Codex reads trust from, in the hook installer's lock order. */ +export function getLocalCodexTrustConfigFiles(): string[] { + return [ + join(getOrcaManagedCodexHomePath(), 'config.toml'), + join(getSystemCodexHomePath(), 'config.toml') + ] +} + export function getCodexSessionBackfillStateDirPath(): string { return join(getOrcaUserDataPath(), 'codex-session-backfill') } diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index d2b5c3b586c..415aabf6155 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -8,6 +8,10 @@ import { writeHooksJson } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { + isAgentStatusHooksEnabledForAgent, + type AgentStatusHooksSettings +} from '../../shared/agent-status-hooks-setting' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { findManagedTomlBlocks } from '../agent-hooks/managed-toml-ownership' import { writeConfigAtomically, type CodexTrustEntry } from './config-toml-trust' @@ -42,6 +46,17 @@ export function setSystemCodexHomeHookSweepSuppressed(gate: () => boolean): void systemCodexHomeHookSweepSuppressed = gate } +// Why per agent: Codex turned off must re-arm the sweep exactly like the global switch off, +// or its remove() leaves Orca's entry in the real ~/.codex. +export function shouldSuppressSystemCodexHomeHookSweep(args: { + isHostSystemDefaultRealHome: boolean + settings: AgentStatusHooksSettings +}): boolean { + return ( + args.isHostSystemDefaultRealHome && isAgentStatusHooksEnabledForAgent(args.settings, 'codex') + ) +} + function getLegacyCodexProfileTomlPath(): string { return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`) } diff --git a/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts b/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts new file mode 100644 index 00000000000..780f0dbbf80 --- /dev/null +++ b/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { CodexHookService } from './hook-service' +import { + setSystemCodexHomeHookSweepSuppressed, + shouldSuppressSystemCodexHomeHookSweep +} from './codex-hook-legacy-cleanup' + +const homes = setupCodexHookHomes(homedirMock, getPathMock) + +type HookSettings = Pick + +const CODEX_OFF: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] } +const ALL_ON: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: [] } +const OTHER_OFF: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: ['claude'] } +const GLOBAL_OFF: HookSettings = { agentStatusHooksEnabled: false, disabledTuiAgents: [] } + +function orcaEntryCommand(): string { + const scriptPath = join( + homes.userDataDir, + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) +} + +function seedRealHomeHooks(): string { + const systemCodexHome = join(homes.tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: orcaEntryCommand() }] } + ] + } + })}\n`, + 'utf-8' + ) + return systemHooksPath +} + +function realHomeStopHooks(systemHooksPath: string): unknown { + return JSON.parse(readFileSync(systemHooksPath, 'utf-8')).hooks.Stop +} + +describe('system ~/.codex sweep gate', () => { + afterEach(() => { + setSystemCodexHomeHookSweepSuppressed(() => false) + }) + + it.each([ + { label: 'Codex turned off', settings: CODEX_OFF, suppressed: false }, + { label: 'the global switch off', settings: GLOBAL_OFF, suppressed: false }, + { label: 'every agent on', settings: ALL_ON, suppressed: true }, + { label: 'another agent turned off', settings: OTHER_OFF, suppressed: true } + ])('on the real-home lane with $label, suppressed=$suppressed', ({ settings, suppressed }) => { + expect( + shouldSuppressSystemCodexHomeHookSweep({ isHostSystemDefaultRealHome: true, settings }) + ).toBe(suppressed) + }) + + it('never suppresses off the real-home lane', () => { + expect( + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: false, + settings: ALL_ON + }) + ).toBe(false) + }) + + it('turning Codex off removes the Orca entry from the real ~/.codex and keeps user hooks', async () => { + const systemHooksPath = seedRealHomeHooks() + setSystemCodexHomeHookSweepSuppressed(() => + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: true, + settings: CODEX_OFF + }) + ) + + await new CodexHookService().remove() + + expect(realHomeStopHooks(systemHooksPath)).toEqual([ + { hooks: [{ type: 'command', command: 'user-hook' }] } + ]) + }) + + it('leaves the real-home entry in place while Codex hooks are on', async () => { + const systemHooksPath = seedRealHomeHooks() + setSystemCodexHomeHookSweepSuppressed(() => + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: true, + settings: ALL_ON + }) + ) + + await new CodexHookService().remove() + + expect(realHomeStopHooks(systemHooksPath)).toEqual([ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: orcaEntryCommand() }] } + ]) + }) +}) diff --git a/src/main/codex/codex-index-heal-binary-contract.test.ts b/src/main/codex/codex-index-heal-binary-contract.test.ts index 5580fc02d1d..8954f618a6a 100644 --- a/src/main/codex/codex-index-heal-binary-contract.test.ts +++ b/src/main/codex/codex-index-heal-binary-contract.test.ts @@ -3,10 +3,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +import { zstdCompressSync } from 'node:zlib' import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import SyncDatabase from '../sqlite/sync-database' import { runCodexAppServerSession, type CodexAppServerRpc } from './codex-app-server-session' -import { findNewestCodexStateDbPath } from './codex-state-db' +import { findNewestCodexStateDbPath, readCodexStateDbBackfillStatus } from './codex-state-db' // Why this file exists: every other index-heal test drives a stub app-server and // asserts "healed" as "the `thread/read` call did not error". That pins Orca's half @@ -17,7 +19,7 @@ import { findNewestCodexStateDbPath } from './codex-state-db' // real-binary backstop, built to the same shape as the Git binary compatibility // contract in src/shared/git-binary-compatibility.test.ts. // -// Keep it narrow. It pins the four arms that ablation established Orca relies on, +// Keep it narrow. It pins the arms that ablation established Orca relies on, // and deliberately asserts nothing else about the app-server, so an unrelated Codex // release does not redden it into being disabled. @@ -83,11 +85,18 @@ describeCodexContract( mkdirSync(join(home, 'sessions'), { recursive: true }) // An app-server session over an empty sessions tree is what stamps the backfill complete. await runAppServerSession(home, async () => undefined) + // Why: the account bridge links history only after this no-request session stamps it. + expect(readCodexStateDbBackfillStatus(home).kind).toBe('complete') expect(readThreadRows(home)).toEqual([]) return home } - function writeRollout(home: string, threadId: string, stamp: string): void { + function writeRollout( + home: string, + threadId: string, + stamp: string, + options: { compressed?: boolean } = {} + ): void { const dayDir = join(home, 'sessions', '2026', '08', '29') mkdirSync(dayDir, { recursive: true }) const meta = { @@ -112,10 +121,13 @@ describeCodexContract( type: 'event_msg', payload: { type: 'user_message', message: 'index-heal contract fixture' } } - writeFileSync( - join(dayDir, `rollout-${stamp}-${threadId}.jsonl`), - `${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n` - ) + const contents = `${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n` + const fileName = `rollout-${stamp}-${threadId}.jsonl` + if (options.compressed) { + writeFileSync(join(dayDir, `${fileName}.zst`), zstdCompressSync(contents)) + } else { + writeFileSync(join(dayDir, fileName), contents) + } } // Why reuse runCodexAppServerSession rather than a local JSON-RPC client: it is the @@ -128,7 +140,8 @@ describeCodexContract( return runCodexAppServerSession( { command: binary!, - args: ['app-server'], + // Why: the heal and account state-DB creation launch with these exact args. + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: binary!, env: { CODEX_HOME: home }, timeoutMs: SESSION_TIMEOUT_MS @@ -171,6 +184,19 @@ describeCodexContract( expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }]) }) + // Why: the account bridge links `.jsonl.zst` history and heals it with the same read. + it('indexes a compressed-only rollout on read', async () => { + const home = await createBackfilledCodexHome() + const threadId = '01a04f62-715e-7830-9371-50db585caa74' + writeRollout(home, threadId, '2026-08-29T17-00-00', { compressed: true }) + + await runAppServerSession(home, async (rpc) => { + await rpc.request('thread/read', { threadId }) + }) + + expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }]) + }) + it('leaves an already-indexed thread as a single row', async () => { const home = await createBackfilledCodexHome() const threadId = '01a04f62-715e-7830-9371-50db585caa72' diff --git a/src/main/codex/codex-journal-command-turn.ts b/src/main/codex/codex-journal-command-turn.ts new file mode 100644 index 00000000000..90e2f556b84 --- /dev/null +++ b/src/main/codex/codex-journal-command-turn.ts @@ -0,0 +1,165 @@ +// The conversation command a Codex child is running. Codex carries a command out as a turn of its +// own; that turn writes no record, its rows join the command's turn, and its end is the command's. +// Held by the child's translator, so it ends with the child and nothing has to release it. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' +import { readCodexTurnId } from './codex-structured-thread-facts' + +type CarriedCommand = { + command: StructuredAgentSessionCommandRun + compacted: boolean + /** Codex's own error row already says why the command failed. */ + failureShown: boolean +} + +export class CodexJournalCommandTurn { + /** Sent, and not yet carried by a provider turn. */ + private awaiting: StructuredAgentSessionCommandRun | null = null + private readonly carried = new Map() + + /** Before Orca sends the command: the next primary turn to start carries it out. */ + begin(command: StructuredAgentSessionCommandRun): void { + this.awaiting = command + } + + /** The command was never taken. */ + forget(turnId: string): void { + if (this.awaiting?.turnId === turnId) { + this.awaiting = null + } + } + + /** The command the primary turn `providerTurnId` carries out. Idempotent per provider turn, so a + * refused frame's retry gets the same answer. */ + claim(providerTurnId: string): StructuredAgentSessionCommandRun | null { + const carried = this.carried.get(providerTurnId) + if (carried || !this.awaiting) { + return carried?.command ?? null + } + const command = this.awaiting + this.awaiting = null + this.carried.set(providerTurnId, { command, compacted: false, failureShown: false }) + return command + } + + isCarrying(providerTurnId: string): boolean { + return this.carried.has(providerTurnId) + } + + scopeFor(providerTurnId: string): AgentJournalTurnScope | null { + const carried = this.carried.get(providerTurnId) + return carried + ? { kind: 'turn', turnItemId: agentJournalItemKey(carried.command.identity) } + : null + } + + /** The provider turn a Stop on `turnId` interrupts: none while the command has not started one. */ + providerTurnId(turnId: string): string | undefined { + if (this.awaiting?.turnId === turnId) { + return undefined + } + for (const [providerTurnId, { command }] of this.carried) { + if (command.turnId === turnId) { + return providerTurnId + } + } + return turnId + } + + /** Codex reported the compaction, in the turn carrying the command. */ + compacted(providerTurnId: string): void { + const carried = this.carried.get(providerTurnId) + if (carried) { + carried.compacted = true + } + } + + /** The command's end, for the batch that settles the provider turn carrying it. */ + end( + providerTurnId: string, + ended: { + status: string | null + error: string | null + completedAt: number + } + ): JournalLifecycleMutationInput[] { + const carried = this.carried.get(providerTurnId) + if (!carried) { + return [] + } + const { command } = carried + const detail = ended.error === null ? undefined : providerDiagnostic(ended.error, 'person') + const verdict = structuredCompactionOutcome({ + compacted: carried.compacted, + // Codex reports the user's stop as the turn's own status. + interruptRequested: ended.status === 'interrupted', + // A turn that did not complete failed, not merely went unconfirmed. + failed: ended.status !== 'completed' || detail ? (detail ? { detail } : {}) : null + }) + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + return [ + // A success already drew Codex's own compaction marker inside the command's turn. + ...(verdict.failure && !carried.failureShown + ? [ + { + kind: 'item' as const, + identity: command.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.codex + }), + tone: 'error' as const + }, + turnScope + } + ] + : []), + { + kind: 'item', + identity: command.identity, + body: agentJournalTurnBody({ + ...command.running, + providerTurnId, + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: verdict.outcome, + completedAt: ended.completedAt + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + + /** Codex's `error` frame, already a row in the turn it names. A turn-ending one says why the + * command failed, so the failed completion that follows it adds no second row. */ + errorShown(params: unknown): void { + const providerTurnId = readCodexTurnId(params) + const carried = providerTurnId ? this.carried.get(providerTurnId) : undefined + // A stream error Codex is about to retry ends nothing. + if (carried && readCodexJournalRecord(params).willRetry !== true) { + carried.failureShown = true + } + } + + settled(providerTurnId: string): void { + this.carried.delete(providerTurnId) + } + + clear(): void { + this.awaiting = null + this.carried.clear() + } +} diff --git a/src/main/codex/codex-journal-turn-scopes.ts b/src/main/codex/codex-journal-turn-scopes.ts new file mode 100644 index 00000000000..eb49f243bf4 --- /dev/null +++ b/src/main/codex/codex-journal-turn-scopes.ts @@ -0,0 +1,45 @@ +// Which turn a Codex row belongs to, stated when the row is written. +// +// A primary-thread row belongs to the turn it names: that turn's lifecycle record, or the +// conversation command that claimed it. A child thread has turns of its own that the timeline +// does not draw, so its rows belong to the primary turn running when they arrive — the work the +// user is watching — or to no turn once the primary is idle. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' + +export class CodexJournalTurnScopes { + constructor( + private readonly deps: { + /** Without it no turn record is keyed, so every row reads as the thread's. */ + sessionId: string | undefined + primaryThreadId: () => string | null + activeTurn: (threadId: string) => string | null + /** The command turn's scope, for a primary turn carrying a conversation command. */ + commandScope: (turnId: string) => AgentJournalTurnScope | null + } + ) {} + + scopeFor(threadId: string, turnId: string | null): AgentJournalTurnScope { + const primary = this.deps.primaryThreadId() + const primaryTurnId = + primary === null ? null : threadId === primary ? turnId : this.deps.activeTurn(primary) + if (primaryTurnId === null) { + return AGENT_JOURNAL_THREAD_SCOPE + } + const { sessionId } = this.deps + return ( + this.deps.commandScope(primaryTurnId) ?? + (sessionId === undefined + ? AGENT_JOURNAL_THREAD_SCOPE + : { + kind: 'turn', + turnItemId: agentJournalItemKey(codexTurnLifecycleIdentity(sessionId, primaryTurnId)) + }) + ) + } +} diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts index a895653b047..dd0cd44e749 100644 --- a/src/main/codex/codex-legacy-session-resume.test.ts +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -135,18 +135,15 @@ describe('prepareLegacySharedCodexSessionResume', () => { } ) - it.each(['managed account', 'custom CODEX_HOME'])( - 'preserves the legacy home while the %s lane is selected', - async () => { - const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), { - ...options(), - isHostSystemDefaultRealHome: () => false - }) + it('preserves the legacy home while a non-default Codex home lane is selected', async () => { + const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), { + ...options(), + isHostSystemDefaultRealHome: () => false + }) - expect(result).toEqual({ useRealCodexHome: false }) - expect(existsSync(targetRolloutPath())).toBe(false) - } - ) + expect(result).toEqual({ useRealCodexHome: false }) + expect(existsSync(targetRolloutPath())).toBe(false) + }) it('still materializes a legacy resume when an account selection exists', async () => { const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), { diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 66914f51991..2cf354259d7 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -1,4 +1,3 @@ -import { getSpawnArgsForWindows } from '../win32-utils' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { runCodexAppServerSession } from './codex-app-server-session' import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' @@ -44,14 +43,11 @@ export function createCodexModelCatalogProbe( ): AgentModelCatalogProbe { return async (accountHomePath: string): Promise => { const { command, environment } = await resolveCodexStructuredInvocation(deps) - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ - ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS - ]) const run = deps.runSession ?? runCodexAppServerSession const listing = await run( { - command: spawnCmd, - args: spawnArgs, + command, + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 9d28d372ece..dcf8ab00512 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -103,26 +103,6 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }) }) - it('records the same account a resume pinned to when it is already selected', () => { - const accounts = [managedAccount({ id: 'account-a' })] - - // Why: the sweep compares this against the live selection, so an equal - // account must still be recorded — it is simply not reported stale. - expect( - resolveCodexPaneLaunchAccount({ - pinnedByResume: true, - launchCodexHomePath: '/data/codex-accounts/account-a/home', - systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: 'account-a', accounts }), - target: { runtime: 'host' } - }) - ).toEqual({ - selectionKey: 'host', - accountId: 'account-a', - homeRoute: 'account-home' - }) - }) - it('maps a resume redirected to the real system home to the system-default account', () => { expect( resolveCodexPaneLaunchAccount({ diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 2c123a3b910..fa6aef48ae9 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' @@ -45,7 +46,7 @@ function fixture(maxMetadataBytes?: number) { { sink, maxMetadataBytes, - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: (run) => { scheduled.add(run) return () => { @@ -119,7 +120,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) } @@ -215,7 +216,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) expect(items.activeItems.size).toBe(1) diff --git a/src/main/codex/codex-prompt-registry-retention.test.ts b/src/main/codex/codex-prompt-registry-retention.test.ts index 4757d731dcf..5a1dbca11ba 100644 --- a/src/main/codex/codex-prompt-registry-retention.test.ts +++ b/src/main/codex/codex-prompt-registry-retention.test.ts @@ -83,7 +83,7 @@ describe('Codex prompt claim lifetime', () => { await collect() expect(old.deref()).toBeUndefined() expect(registry.find(replacement.itemId)).toBe(replacement.prompt) - expect(registry.ownsBoundClaim(claim, replacement.itemId, 'thread', 'new-turn')).toBe(true) + expect(registry.ownsClaim(claim)).toBe(true) registry.clearTurn('thread', 'new-turn') expect(registry.ownsClaim(claim)).toBe(false) }) diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts index eda1fa0bf30..b7719594886 100644 --- a/src/main/codex/codex-prompt-registry.ts +++ b/src/main/codex/codex-prompt-registry.ts @@ -191,23 +191,6 @@ export class CodexPromptRegistry { return this.claims.get(claim.prompt) === claim && this.find(claim.itemId) === claim.prompt } - ownsBoundClaim( - claim: CodexPromptClaim, - journalItemId: string, - threadId: string, - turnId: string - ): boolean { - return ( - claim.itemId === journalItemId && - this.claims.get(claim.prompt) === claim && - this.journalItemIds.get(journalItemId) === - this.address(claim.prompt.threadId, claim.prompt.promptKey) && - this.boundPrompts.get(journalItemId) === claim.prompt && - claim.prompt.threadId === threadId && - codexPromptMatchesTurn(claim.prompt, turnId) - ) - } - releaseClaim(claim: CodexPromptClaim): void { if (this.claims.get(claim.prompt) === claim) { this.claims.delete(claim.prompt) diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index fbf50badaef..a7baf6a14b5 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -72,6 +73,7 @@ describe('requested-close durable turn timing', () => { reportedOptions: {}, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator } const sessions = new Map([['session-1', session]]) diff --git a/src/main/codex/codex-server-request-disposition.test.ts b/src/main/codex/codex-server-request-disposition.test.ts index 371927513bc..ec3a193af6c 100644 --- a/src/main/codex/codex-server-request-disposition.test.ts +++ b/src/main/codex/codex-server-request-disposition.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import { CODEX_ATTESTATION_METHOD, CODEX_AUTH_TOKEN_REFRESH_METHOD, - CODEX_BLOCKING_SERVER_REQUEST_METHODS, CODEX_DYNAMIC_TOOL_CALL_METHOD, CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, CODEX_LEGACY_EXEC_APPROVAL_METHOD, @@ -95,23 +94,6 @@ describe('Codex blocking server request dispositions', () => { ) }) - it('enumerates every server request in the negotiated stable schema', () => { - expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual( - new Set([ - CODEX_COMMAND_APPROVAL_METHOD, - CODEX_FILE_CHANGE_APPROVAL_METHOD, - CODEX_USER_INPUT_METHOD, - CODEX_MCP_ELICITATION_METHOD, - CODEX_PERMISSIONS_APPROVAL_METHOD, - CODEX_DYNAMIC_TOOL_CALL_METHOD, - CODEX_AUTH_TOKEN_REFRESH_METHOD, - CODEX_ATTESTATION_METHOD, - CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, - CODEX_LEGACY_EXEC_APPROVAL_METHOD - ]) - ) - }) - it('bounds the future-method fallback to one explicit rejection', () => { const { registry, connection } = harness() diff --git a/src/main/codex/codex-server-request-disposition.ts b/src/main/codex/codex-server-request-disposition.ts index 4e358da86b1..438bbfcc92b 100644 --- a/src/main/codex/codex-server-request-disposition.ts +++ b/src/main/codex/codex-server-request-disposition.ts @@ -18,19 +18,6 @@ export const CODEX_ATTESTATION_METHOD = 'attestation/generate' export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval' export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval' -export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [ - CODEX_COMMAND_APPROVAL_METHOD, - CODEX_FILE_CHANGE_APPROVAL_METHOD, - CODEX_USER_INPUT_METHOD, - CODEX_MCP_ELICITATION_METHOD, - CODEX_PERMISSIONS_APPROVAL_METHOD, - CODEX_DYNAMIC_TOOL_CALL_METHOD, - CODEX_AUTH_TOKEN_REFRESH_METHOD, - CODEX_ATTESTATION_METHOD, - CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, - CODEX_LEGACY_EXEC_APPROVAL_METHOD -] as const - export type CodexServerRequestDisposition = | { kind: 'prompt'; prompt: CodexPendingPrompt } | { kind: 'responded'; method: string } diff --git a/src/main/codex/codex-session-backfill-scan-dates.test.ts b/src/main/codex/codex-session-backfill-scan-dates.test.ts index 8e7001c636c..da693396089 100644 --- a/src/main/codex/codex-session-backfill-scan-dates.test.ts +++ b/src/main/codex/codex-session-backfill-scan-dates.test.ts @@ -143,7 +143,6 @@ describe('bounded backfill range construction', () => { ['EU spring-forward', ['2025', '03', '29'], ['2025', '03', '31']], ['southern-hemisphere DST', ['2025', '04', '05'], ['2025', '04', '07']], ['year rollover', ['2024', '12', '30'], ['2025', '01', '02']], - ['leap century', ['1999', '12', '31'], ['2000', '01', '02']], ['non-leap century', ['2100', '02', '27'], ['2100', '03', '02']], ['30-day month end', ['2026', '04', '29'], ['2026', '05', '02']], ['single day', ['2026', '09', '07'], ['2026', '09', '07']] diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index a3dff66c6f0..fed396bf7d2 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -298,20 +298,6 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['hardlink', 'run-summary']) }) - it('skips cross-volume rollouts instead of freezing a mutable snapshot', async () => { - fsMockState.failLink = true - const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl') - writeManagedSession(relativePath, '{"id":"a"}\n') - - const summary = await backfillManagedCodexSessionsIntoSystemHome( - resolveCodexSessionBackfillPaths() - ) - - expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 }) - expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) - expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) - }) - it('fails closed when the target filesystem cannot install without overwrite', async () => { fsMockState.failLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') @@ -332,21 +318,6 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) - it('keeps transient hardlink failures retryable', async () => { - fsMockState.failLinkTransiently = true - writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') - - const summary = await backfillManagedCodexSessionsIntoSystemHome( - resolveCodexSessionBackfillPaths() - ) - - expect(summary).toMatchObject({ - skippedUnsupportedFilesystemFiles: 0, - failedFiles: 1 - }) - expect(readAuditActions()).toEqual(['failed', 'run-summary']) - }) - it('keeps target directory permission failures retryable', async () => { const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') writeManagedSession(relativePath, '{"id":"a"}\n') diff --git a/src/main/codex/codex-session-index-heal-state.test.ts b/src/main/codex/codex-session-index-heal-state.test.ts index 92270911dfe..25189fdbf1b 100644 --- a/src/main/codex/codex-session-index-heal-state.test.ts +++ b/src/main/codex/codex-session-index-heal-state.test.ts @@ -3,7 +3,6 @@ import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - CODEX_SESSION_INDEX_HEAL_VERSION, appendHealLedgerRecord, collectPendingHealThreads, isHealMarkerCurrent, @@ -149,6 +148,5 @@ describe('codex session index heal state', () => { expect(await collectPendingHealThreads(paths)).toEqual([ expect.objectContaining({ threadId: THREAD_ID }) ]) - expect(CODEX_SESSION_INDEX_HEAL_VERSION).toBe(3) }) }) diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 0a5fd0499c1..ab62b77669d 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -22,7 +22,7 @@ const HEAL_UNSUPPORTED_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const HEAL_FAILED_THREAD_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const CODEX_ROLLOUT_THREAD_ID_PATTERN = - /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i + /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i export type CodexSessionIndexHealPaths = { auditLogPath: string @@ -70,11 +70,11 @@ export async function collectPendingHealThreads( if (!isPathInsideOrEqual(paths.systemSessionsRoot, line.target)) { continue } - const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) - if (!match) { + const rollout = parseCodexRolloutThreadId(line.target) + if (!rollout) { continue } - const threadId = match[2].toLowerCase() + const { threadId } = rollout const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( auditRecordId @@ -88,13 +88,21 @@ export async function collectPendingHealThreads( pendingByThreadId.delete(threadId) continue } - pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1], auditRecordId }) + pendingByThreadId.set(threadId, { threadId, rolloutStamp: rollout.rolloutStamp, auditRecordId }) } return [...pendingByThreadId.values()].sort((left, right) => left.rolloutStamp < right.rolloutStamp ? 1 : left.rolloutStamp > right.rolloutStamp ? -1 : 0 ) } +/** Thread id (lower-cased) and timestamp segment encoded in a rollout file name. */ +export function parseCodexRolloutThreadId( + filePath: string +): { threadId: string; rolloutStamp: string } | null { + const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(filePath)) + return match ? { threadId: match[2].toLowerCase(), rolloutStamp: match[1] } : null +} + function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 20051fe1058..d7a6c442286 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -14,6 +14,7 @@ import type { CodexAppServerInvocation } from './codex-app-server-session' import { createCodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' import { CODEX_SESSION_INDEX_HEAL_VERSION } from './codex-session-index-heal-state' import { + buildNativeHealInvocation, runCodexSessionIndexHeal, type CodexSessionIndexHealPaths } from './codex-session-index-heal' @@ -340,6 +341,7 @@ describe('runCodexSessionIndexHeal', () => { const marker = JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8')) as { retryableFailureAt: number } + expect(marker.retryableFailureAt).toEqual(expect.any(Number)) marker.retryableFailureAt = 0 writeFileSync(rig.paths.healMarkerPath, `${JSON.stringify(marker)}\n`, 'utf-8') const retried = await runCodexSessionIndexHeal(rig.paths, { @@ -490,6 +492,25 @@ describe('runCodexSessionIndexHeal', () => { expect(resumed.healedThreads + summary.healedThreads).toBe(4) }) + it('writes no completion marker when stop flips inside the last batch', async () => { + const rig = createHealRig({ + auditedThreads: [ + { stamp: '2026-07-02T10-00-00', id: threadId('2') }, + { stamp: '2026-07-01T10-00-00', id: threadId('1') } + ] + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readConcurrency: 1, + interBatchDelayMs: 0, + shouldStop: () => rig.readLog().threadIds.length > 0 + }) + + expect(summary).toMatchObject({ outcome: 'stopped', healedThreads: 1 }) + expect(existsSync(rig.paths.healMarkerPath)).toBe(false) + }) + it('does not spawn another server when stop flips during the inter-batch delay', async () => { const rig = createHealRig({ auditedThreads: [ @@ -777,3 +798,11 @@ describe('runCodexSessionIndexHeal', () => { warnSpy.mockRestore() }) }) + +describe('buildNativeHealInvocation', () => { + it('pins the app-server to the given home', () => { + const invocation = buildNativeHealInvocation('/codex-home', 1_000) + + expect(invocation.env).toEqual({ CODEX_HOME: '/codex-home' }) + }) +}) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 12747312646..5ce55c8e082 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -1,7 +1,7 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' -import { getSpawnArgsForWindows } from '../win32-utils' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' import { @@ -17,7 +17,8 @@ import { import { isCodexAppServerUnsupportedError, runCodexAppServerSession, - type CodexAppServerInvocation + type CodexAppServerInvocation, + type CodexAppServerRpc } from './codex-app-server-session' export type { CodexSessionIndexHealPaths } from './codex-session-index-heal-state' @@ -49,12 +50,20 @@ export type CodexSessionIndexHealSummary = { export type CodexSessionIndexHealOptions = { /** Polled between reads and batches; true stops promptly, progress is kept. */ shouldStop?: () => boolean - buildInvocation?: (systemCodexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + buildInvocation?: (codexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + runSession?: ( + invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ) => Promise readsPerServerSession?: number readConcurrency?: number interBatchDelayMs?: number } +export type CodexThreadReadOutcome = HealLedgerOutcome + +export type CodexThreadReadPassOutcome = 'completed' | 'stopped' | 'unsupported' | 'aborted' + let backgroundHealTask: Promise | null = null export function resolveCodexSessionIndexHealPaths( @@ -132,69 +141,30 @@ export async function runCodexSessionIndexHeal( return summary } - const systemCodexHomePath = dirname(paths.systemSessionsRoot) - const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation - const readsPerServerSession = resolveHealWorkLimit( - options.readsPerServerSession, - HEAL_READS_PER_SERVER_SESSION - ) - const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) - const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS const shouldStop = options.shouldStop ?? ((): boolean => false) - - for (let offset = 0; offset < pending.length; offset += readsPerServerSession) { - if (shouldStop()) { - summary.outcome = 'stopped' - return summary - } - if (offset > 0 && interBatchDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) - if (shouldStop()) { - // Why: opt-out can happen during the throttle delay; do not spawn a - // real-home app-server after the lane has been disabled. - summary.outcome = 'stopped' - return summary + const outcome = await readCodexThreadsForIndexHeal( + dirname(paths.systemSessionsRoot), + pending, + (thread, readOutcome) => { + if (readOutcome === 'healed') { + summary.healedThreads += 1 + } else if (readOutcome === 'missing') { + // The backfilled rollout was deleted after the audit was written. + summary.missingThreads += 1 + } else { + summary.failedThreads += 1 } - } - const batch = pending.slice(offset, offset + readsPerServerSession) - const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length - try { - await runCodexAppServerSession( - buildInvocation(systemCodexHomePath, timeoutMs), - async (rpc) => { - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < batch.length && !shouldStop()) { - const thread = batch[nextIndex] - nextIndex += 1 - await healOneThread(rpc, thread, paths, summary) - } - } - await Promise.all(Array.from({ length: readConcurrency }, () => worker())) - } - ) - } catch (error) { - if (isCodexAppServerUnsupportedError(error)) { - if (shouldStop()) { - summary.outcome = 'stopped' - return summary - } - // Why: no retry churn on old CLIs — remember unsupported and re-probe - // after the retry interval or a version bump; nothing is marked healed. - writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) - summary.outcome = 'unsupported' - return summary - } - // Transport failure (timeout, early exit, spawn error): unprocessed ids - // were never appended to the ledger, so the next pass resumes them. - console.warn('[codex-session-index-heal] Heal batch aborted:', error) - summary.outcome = 'aborted' - return summary - } + recordHealOutcome(paths, thread, readOutcome) + }, + options + ) + if (outcome === 'unsupported') { + // Why: no retry churn on old CLIs — remember unsupported and re-probe + // after the retry interval or a version bump; nothing is marked healed. + writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) } - - if (shouldStop()) { - summary.outcome = 'stopped' + if (outcome !== 'completed' || shouldStop()) { + summary.outcome = outcome === 'completed' ? 'stopped' : outcome return summary } writeHealMarker( @@ -206,16 +176,76 @@ export async function runCodexSessionIndexHeal( return summary } -async function healOneThread( - rpc: { request: (method: string, params?: Record) => Promise }, - thread: PendingHealThread, - paths: CodexSessionIndexHealPaths, - summary: CodexSessionIndexHealSummary -): Promise { +/** + * Drives `thread/read` over `threads` in bounded app-server batches, reporting + * each settled read. Transport failures and sqlite contention abort the pass + * without reporting, so the caller's next pass retries those threads. + */ +export async function readCodexThreadsForIndexHeal( + codexHomePath: string, + threads: readonly T[], + onOutcome: (thread: T, outcome: CodexThreadReadOutcome) => void, + options: CodexSessionIndexHealOptions = {} +): Promise { + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const runSession = options.runSession ?? runCodexAppServerSession + const readsPerServerSession = resolveHealWorkLimit( + options.readsPerServerSession, + HEAL_READS_PER_SERVER_SESSION + ) + const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) + const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS + const shouldStop = options.shouldStop ?? ((): boolean => false) + + for (let offset = 0; offset < threads.length; offset += readsPerServerSession) { + if (shouldStop()) { + return 'stopped' + } + if (offset > 0 && interBatchDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) + if (shouldStop()) { + // Why: opt-out can happen during the throttle delay; do not spawn an + // app-server after the lane has been disabled. + return 'stopped' + } + } + const batch = threads.slice(offset, offset + readsPerServerSession) + const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length + try { + await runSession(buildInvocation(codexHomePath, timeoutMs), async (rpc) => { + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < batch.length && !shouldStop()) { + const thread = batch[nextIndex] + nextIndex += 1 + onOutcome(thread, await readOneThread(rpc, thread.threadId)) + } + } + await Promise.all(Array.from({ length: readConcurrency }, () => worker())) + }) + } catch (error) { + if (shouldStop()) { + return 'stopped' + } + if (isCodexAppServerUnsupportedError(error)) { + return 'unsupported' + } + // Transport failure (timeout, early exit, spawn error): unprocessed ids + // were never reported, so the next pass resumes them. + console.warn('[codex-session-index-heal] Heal batch aborted:', error) + return 'aborted' + } + } + return 'completed' +} + +async function readOneThread( + rpc: CodexAppServerRpc, + threadId: string +): Promise { try { - await rpc.request('thread/read', { threadId: thread.threadId }) - summary.healedThreads += 1 - recordHealOutcome(paths, thread, 'healed') + await rpc.request('thread/read', { threadId }) + return 'healed' } catch (error) { if (isCodexAppServerUnsupportedError(error)) { throw error @@ -223,22 +253,18 @@ async function healOneThread( const message = error instanceof Error ? error.message : String(error) if (!message.startsWith('codex app-server thread/read failed')) { // Not an RPC-level response: the server died or timed out. Abort the - // batch without recording, so the id is retried on the next pass. + // batch without reporting, so the id is retried on the next pass. throw error } if (/no rollout found/i.test(message)) { - // The backfilled rollout was deleted after the audit was written. - summary.missingThreads += 1 - recordHealOutcome(paths, thread, 'missing') - return + return 'missing' } if (isTransientSqliteContention(message)) { - // Why: an active Codex process can briefly own sqlite; leave the id off - // the ledger and abort this pass so a later startup resumes it. + // Why: an active Codex process can briefly own sqlite; leave the id + // unreported and abort this pass so a later startup resumes it. throw error } - summary.failedThreads += 1 - recordHealOutcome(paths, thread, 'failed') + return 'failed' } } @@ -259,20 +285,20 @@ function resolveHealWorkLimit(value: number | undefined, maximum: number): numbe return Math.min(Math.floor(value), maximum) } -function buildNativeHealInvocation( - systemCodexHomePath: string, +export function buildNativeHealInvocation( + codexHomePath: string, timeoutMs: number ): CodexAppServerInvocation { const command = resolveCodexCommand() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) return { - command: spawnCmd, - args: spawnArgs, + command, + // Why: each session is torn down after one batch, so plugin startup could + // leave marketplace clones running; indexing needs neither plugins nor tools. + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, - // Why: pin the real home explicitly — nested Orca launches can inherit a - // managed CODEX_HOME from the daemon environment, which would index the - // wrong sqlite DB. - env: { CODEX_HOME: systemCodexHomePath }, + // Why: pin the home explicitly — nested Orca launches can inherit a managed + // CODEX_HOME from the daemon environment, which would index the wrong sqlite DB. + env: { CODEX_HOME: codexHomePath }, timeoutMs } } diff --git a/src/main/codex/codex-short-lived-app-server-spawn.test.ts b/src/main/codex/codex-short-lived-app-server-spawn.test.ts new file mode 100644 index 00000000000..cdb67ff4640 --- /dev/null +++ b/src/main/codex/codex-short-lived-app-server-spawn.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' +import type { CodexAppServerInvocation } from './codex-app-server-session' + +const NPM_CODEX_SHIM = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd' + +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => NPM_CODEX_SHIM })) + +import { buildNativeHealInvocation } from './codex-session-index-heal' +import { resolveNativeCodexTrustGrantHost } from './codex-trust-grant-host' +import { createCodexModelCatalogProbe } from './codex-model-catalog-probe' + +// Why: spawnProcess resolves npm's codex.cmd past cmd.exe, but only when it is handed the shim. +// A builder that pre-wraps it in `cmd.exe /d /c` puts cmd.exe back into every short-lived +// Codex launch — hundreds of them while a large history indexes. +describe('short-lived Codex app-server invocations on Windows', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('hands the index-heal session the shim itself', () => { + const invocation = buildNativeHealInvocation('C:\\homes\\a', 1_000) + expect(invocation.command).toBe(NPM_CODEX_SHIM) + expect(invocation.cliPath).toBe(NPM_CODEX_SHIM) + expect(invocation.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) + }) + + it('hands the hook trust grant session the shim itself', () => { + const request = resolveNativeCodexTrustGrantHost().buildRequest({ + runtimeHomePath: 'C:\\homes\\a', + managedCommand: 'orca-hook', + expectedTrustKeys: [] + }) + expect(request.invocation.command).toBe(NPM_CODEX_SHIM) + expect(request.invocation.args).toEqual(['app-server']) + }) + + it('hands the model catalog probe session the shim itself', async () => { + const invocations: CodexAppServerInvocation[] = [] + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: 'C:\\bin' }), + resolveCommand: () => NPM_CODEX_SHIM, + runSession: async (invocation, body) => { + invocations.push(invocation) + return body({ + request: async () => ({ + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + isDefault: true + } + ], + nextCursor: null + }), + notify: () => {} + }) + } + }) + await probe('C:\\homes\\a') + expect(invocations[0]?.command).toBe(NPM_CODEX_SHIM) + expect(invocations[0]?.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) + }) +}) diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 027d059f342..7a87fee713c 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' @@ -16,7 +16,6 @@ import { recordCodexPaneAccount } from './codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts' -import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' let userDataPath: string let previousUserDataPath: string | undefined @@ -39,7 +38,6 @@ beforeEach(() => { }) afterEach(() => { - __resetShellStartupEnvCache() rmSync(userDataPath, { recursive: true, force: true }) if (previousUserDataPath === undefined) { delete process.env.ORCA_USER_DATA_PATH @@ -464,23 +462,6 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) - it('does not report a custom-home spelling change that keeps the shared route', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'shared-home', - environmentHomeOverride: { codexHome: '/custom/codex-a' } - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'shared-home' - }) - ).toEqual([]) - }) - it('leaves a retained pane alone while its process CODEX_HOME is unchanged', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', @@ -498,76 +479,6 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) - it('reports when removing a custom home changes the resolved route', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'shared-home', - environmentHomeOverride: { codexHome: '/custom/codex-home' } - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: null, - reason: 'home-route-change' - } - ]) - }) - - it.skipIf(process.platform === 'win32')( - 'reports a retained pane after its shell startup CODEX_HOME is removed', - () => { - const paneHome = join(userDataPath, 'pane-home') - mkdirSync(paneHome, { recursive: true }) - const startupPath = join(paneHome, '.zshrc') - const customHome = join(paneHome, 'custom-codex-home') - writeFileSync(startupPath, 'export CODEX_HOME="$HOME/custom-codex-home"\n') - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'shared-home', - shellStartupHomeOverride: { - home: paneHome, - shell: '/bin/zsh', - codexHome: customHome - } - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'shared-home' - }) - ).toEqual([]) - - writeFileSync(startupPath, '') - __resetShellStartupEnvCache() - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: null, - reason: 'home-route-change' - } - ]) - } - ) - it('never reports an unrecorded PTY, so an upgrade cannot invent a prompt', () => { expect( listStaleCodexPanes({ ptyIds: ['pty-unknown'], settings: settingsWithSelection('account-b') }) diff --git a/src/main/codex/codex-state-db-backfill-recovery.test.ts b/src/main/codex/codex-state-db-backfill-recovery.test.ts index 1986ccbdae9..cc20eef34df 100644 --- a/src/main/codex/codex-state-db-backfill-recovery.test.ts +++ b/src/main/codex/codex-state-db-backfill-recovery.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import * as ownerIdentity from '../agent-hooks/managed-hook-owner-identity' import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' -import { getCmdExePath } from '../win32-utils' import { _internals, resolveCodexBackfillSupervisorLockRoot, @@ -365,7 +364,7 @@ describe('Codex state DB backfill recovery', () => { expect(terminate).toHaveBeenCalledWith(child) }) - it('uses batch-safe read-only arguments for native Windows recovery', async () => { + it('hands the Codex shim itself to the spawn chokepoint for native Windows recovery', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const child = createFakeChild() const spawnProcess = vi.fn(() => child) @@ -393,11 +392,9 @@ describe('Codex state DB backfill recovery', () => { string[], { cwd?: string; env?: NodeJS.ProcessEnv } ] - expect(spawnFile).toBe(getCmdExePath()) + // Why: spawnProcess resolves a recognised npm shim past cmd.exe; pre-wrapping hides the shim. + expect(spawnFile).toBe(codexCommand) expect(spawnArgs).toEqual([ - '/d', - '/c', - codexCommand, '-c', 'approval_policy=never', '-s', diff --git a/src/main/codex/codex-state-db-backfill-recovery.ts b/src/main/codex/codex-state-db-backfill-recovery.ts index 16d56d5907f..b4acef3b337 100644 --- a/src/main/codex/codex-state-db-backfill-recovery.ts +++ b/src/main/codex/codex-state-db-backfill-recovery.ts @@ -1,4 +1,3 @@ -import { spawn, type ChildProcess } from 'node:child_process' import { createHash } from 'node:crypto' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' @@ -11,7 +10,11 @@ import { resolveCodexCommand } from '../codex-cli/command' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { terminateCodexProbeChild } from '../rate-limits/codex-probe-termination' -import { getSpawnArgsForWindows } from '../win32-utils' +import type { ChildProcessHandle } from '../../shared/child-process/process-spec' +import { + spawnCodexAppServerProcess, + type CodexAppServerSpawn +} from './codex-app-server-process-tree-kill' import { getOrcaUserDataPath } from './codex-home-paths' import { BACKFILL_PENDING_MIN_SESSION_FILES, @@ -34,17 +37,17 @@ export type CodexStateDbBackfillRecoverySummary = { } type RecoveryDependencies = { - spawnProcess: typeof spawn + spawnProcess: CodexAppServerSpawn resolveCommand: () => string readStatus: (codexHomePath: string) => CodexStateDbBackfillStatus countSessions: (sessionsRoot: string, limit: number) => number now: () => number sleep: (ms: number, signal: AbortSignal) => Promise - terminate: (child: ChildProcess) => Promise + terminate: (child: ChildProcessHandle) => Promise } const defaultDependencies: RecoveryDependencies = { - spawnProcess: spawn, + spawnProcess: spawnCodexAppServerProcess, resolveCommand: resolveCodexCommand, readStatus: readCodexStateDbBackfillStatus, countSessions: countCodexSessionFilesUpTo, @@ -86,7 +89,7 @@ function initialRecoveryDecision( function spawnRecoveryProcess( codexHomePath: string, dependencies: RecoveryDependencies -): ChildProcess { +): ChildProcessHandle { const wslHome = process.platform === 'win32' ? parseWslUncPath(codexHomePath) : null if (wslHome) { return dependencies.spawnProcess( @@ -104,10 +107,7 @@ function spawnRecoveryProcess( ) } const command = dependencies.resolveCommand() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ - ...CODEX_READ_ONLY_APP_SERVER_ARGS - ]) - return dependencies.spawnProcess(spawnCmd, spawnArgs, { + return dependencies.spawnProcess(command, [...CODEX_READ_ONLY_APP_SERVER_ARGS], { cwd: codexHomePath, stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, diff --git a/src/main/codex/codex-state-db-test-fixture.ts b/src/main/codex/codex-state-db-test-fixture.ts new file mode 100644 index 00000000000..e84f0328c3b --- /dev/null +++ b/src/main/codex/codex-state-db-test-fixture.ts @@ -0,0 +1,14 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' + +/** Writes a Codex state DB whose startup backfill reports `status`. */ +export function writeCodexStateDbBackfillStatus(codexHomePath: string, status: string): void { + mkdirSync(codexHomePath, { recursive: true }) + const db = new SyncDatabase(join(codexHomePath, 'state_5.sqlite')) + db.exec( + 'CREATE TABLE backfill_state (id INTEGER PRIMARY KEY, status TEXT NOT NULL); ' + + `INSERT INTO backfill_state (id, status) VALUES (1, '${status}')` + ) + db.close() +} diff --git a/src/main/codex/codex-state-db.test.ts b/src/main/codex/codex-state-db.test.ts index 7cf0ac88ac5..a89a556b807 100644 --- a/src/main/codex/codex-state-db.test.ts +++ b/src/main/codex/codex-state-db.test.ts @@ -1,12 +1,13 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SyncDatabase from '../sqlite/sync-database' import { findNewestCodexStateDbPath, isCodexStateDbBackfillPending, - readCodexStateDbBackfillStatus + readCodexStateDbBackfillStatus, + readIndexedCodexThreadIds } from './codex-state-db' const temporaryHomes: string[] = [] @@ -67,6 +68,19 @@ describe('Codex state DB backfill status', () => { expect(isCodexStateDbBackfillPending(home)).toBe(true) }) + it('counts compressed rollouts, which Codex also backfills', async () => { + const home = await createHome() + const sessions = join(home, 'sessions', '2026', '08', '04') + await mkdir(sessions, { recursive: true }) + await Promise.all( + Array.from({ length: 100 }, (_, index) => + writeFile(join(sessions, `rollout-${index}.jsonl.zst`), '') + ) + ) + + expect(isCodexStateDbBackfillPending(home)).toBe(true) + }) + it('does not call a complete backfill pending', async () => { const home = await createHome() createBackfillDb(home, 5, 'complete') @@ -74,3 +88,27 @@ describe('Codex state DB backfill status', () => { expect(isCodexStateDbBackfillPending(home)).toBe(false) }) }) + +describe('readIndexedCodexThreadIds', () => { + it('returns lower-cased thread ids from the newest state DB', async () => { + const home = await createHome() + const path = createBackfillDb(home, 5, 'complete') + const db = new SyncDatabase(path) + db.exec( + "CREATE TABLE threads (id TEXT PRIMARY KEY); INSERT INTO threads (id) VALUES ('ABC'), ('def')" + ) + db.close() + + expect(readIndexedCodexThreadIds(home)).toEqual(new Set(['abc', 'def'])) + }) + + it('returns null when there is no state DB or no threads table', async () => { + const home = await createHome() + expect(readIndexedCodexThreadIds(home)).toBeNull() + + createBackfillDb(home, 5, 'complete') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(readIndexedCodexThreadIds(home)).toBeNull() + warn.mockRestore() + }) +}) diff --git a/src/main/codex/codex-state-db.ts b/src/main/codex/codex-state-db.ts index e2b437aea3a..ae8ff3eb046 100644 --- a/src/main/codex/codex-state-db.ts +++ b/src/main/codex/codex-state-db.ts @@ -71,6 +71,37 @@ export function readCodexStateDbBackfillStatus(codexHomePath: string): CodexStat } } +/** + * Lower-cased thread ids already in Codex's state DB, or null when the DB is + * missing or unreadable. Read-only; never creates or mutates Codex's database. + */ +export function readIndexedCodexThreadIds(codexHomePath: string): Set | null { + const stateDbPath = findNewestCodexStateDbPath(codexHomePath) + if (!stateDbPath) { + return null + } + let db: SyncDatabase | null = null + try { + db = new SyncDatabase(stateDbPath, { readonly: true, fileMustExist: true }) + const ids = new Set() + for (const row of db.prepare('SELECT id FROM threads').all()) { + if (typeof row.id === 'string') { + ids.add(row.id.toLowerCase()) + } + } + return ids + } catch (error) { + console.warn('[codex-state-db] Failed to read indexed Codex threads:', error) + return null + } finally { + try { + db?.close() + } catch { + // A close failure cannot change the read-only result already collected. + } + } +} + export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): number { let count = 0 const pendingDirectories = [sessionsRoot] @@ -85,7 +116,11 @@ export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): for (const entry of entries) { if (entry.isDirectory()) { pendingDirectories.push(join(directory, entry.name)) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if ( + entry.isFile() && + // Why: Codex's startup backfill parses compressed rollouts too. + (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zst')) + ) { count += 1 if (count >= limit) { break diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 175b6cfb734..4317b5669d7 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -390,7 +390,7 @@ describe('Codex structured child-work producer', () => { live: 'working', parent: { stateName: 'working' } }, - // Codex ends this child's turn with an error it will not retry, and no turn/completed. + // Codex ends this child's turn with an error it will not retry, then a failed completion. { frame: { method: 'error', @@ -399,6 +399,13 @@ describe('Codex structured child-work producer', () => { lead: 'working', live: 'working', parent: { stateName: 'working' }, + check: () => expect(byDescription('lint')).toMatchObject({ membership: 'live' }) + }, + { + frame: turn('turn/completed', LINTER, 'l1', 'failed'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index 437f32f0a8f..fa8800f9605 100644 --- a/src/main/codex/codex-structured-compaction-refusal.test.ts +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -1,6 +1,7 @@ // A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. import { describe, expect, it } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { CodexAppServerRequestError } from './codex-app-server-connection' import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' @@ -17,12 +18,25 @@ describe('Codex compaction refused at the request', () => { } }) const adapter = await acquired(codex) + const turn = structuredAgentSessionCommandTurn('cmd-1') await expect( - adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) + adapter.compact({ + sessionId: 'session-1', + fence: 7, + command: { + clientMessageId: 'cmd-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } + }) ).resolves.toEqual({ - outcome: 'failed', - detail: { text: 'thread has nothing to compact', audience: 'person' } + state: 'rejected', + reason: 'The provider did not accept this message: thread has nothing to compact.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread has nothing to compact', audience: 'person' } + } }) }) }) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts new file mode 100644 index 00000000000..5d8a9c5627f --- /dev/null +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -0,0 +1,100 @@ +// A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers +// `turn/start` before it opens the turn, and refuses an interrupt until then. + +import { describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { + CODEX_TEST_THREAD_ID, + codexTurnLifecycleRig +} from './codex-structured-dispatch-test-support' + +type Rig = Awaited> + +async function openedTurn(rig: Rig): Promise { + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + await sending +} + +const stop = (rig: Rig, resolveLiveTurnId?: () => string | null) => + rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + ...(resolveLiveTurnId ? { resolveLiveTurnId } : {}) + }) + +describe('a Codex Stop that names no turn', () => { + it('interrupts the turn Codex opened when the journal shows none yet', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + }) + + it('interrupts no turn that already ended', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + rig.turns.end('completed') + + await expect(stop(rig)).resolves.toEqual({ cancelled: false }) + expect(rig.interrupts()).toEqual([]) + }) + + it("names the journal's turn over the one it saw open, and carries Codex's refusal", async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(stop(rig, () => 'turn-journal')).resolves.toEqual({ + cancelled: false, + refusal: { + detail: { + text: 'expected active turn id turn-journal but found turn-1', + audience: 'person' + } + } + }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-journal']) + }) + + it('interrupts nothing for another fence', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 6 })).resolves.toEqual({ + cancelled: false + }) + expect(rig.interrupts()).toEqual([]) + }) + + it('interrupts no earlier turn while a compaction the journal shows has not started', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + rig.turns.end('completed') + const turn = structuredAgentSessionCommandTurn('operation-1') + const compaction = rig.adapter.compact({ + sessionId: 'session-1', + fence: 7, + command: { + clientMessageId: 'operation-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } + }) + await vi.waitFor(() => + expect(rig.codex.connections[0]!.calls.at(-1)?.method).toBe('thread/compact/start') + ) + + await expect(stop(rig, () => turn.turnId)).resolves.toEqual({ cancelled: false }) + expect(rig.interrupts()).toEqual([]) + + rig.notify('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact' } }) + rig.notify('thread/compacted', { threadId: CODEX_TEST_THREAD_ID }) + rig.notify('turn/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-compact', status: 'completed' } + }) + await expect(compaction).resolves.toEqual({ state: 'accepted', providerIdentity: null }) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index b8ea81d592c..b3c8a3fefeb 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -7,6 +7,7 @@ import { acquiredCodexAdapter, echoUserMessage, fakeCodexAppServer, + openAfterTurnStarts, startTurn, CODEX_TEST_THREAD_ID, CODEX_TEST_USER_MESSAGE, @@ -215,8 +216,9 @@ describe('codex dispatch admission', () => { await expect(send(adapter, 'client-unknown', 1_700_000_000_100)).rejects.toThrow( 'request timed out after write' ) - await send(adapter, 'client-later', 1_700_000_000_400) - startTurn(connection, 'turn-later') + const later = send(adapter, 'client-later', 1_700_000_000_400) + await openAfterTurnStarts(connection, 2, () => startTurn(connection, 'turn-later')) + await later echoUserMessage(connection, { turnId: 'turn-later', itemId: 'item-later', @@ -279,9 +281,10 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - await send(adapter, 'client-opening', 1_700_000_000_600) - await send(adapter, 'client-queued', 1_700_000_000_200) - startTurn(connection, 'turn-1') + const opening = send(adapter, 'client-opening', 1_700_000_000_600) + const queued = send(adapter, 'client-queued', 1_700_000_000_200) + await openAfterTurnStarts(connection, 2, () => startTurn(connection, 'turn-1')) + await Promise.all([opening, queued]) await send(adapter, 'client-mid-turn', 1_700_000_000_100) echoUserMessage(connection, { @@ -325,8 +328,9 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - await send(adapter, 'client-late-echo', 1_700_000_000_100) - startTurn(connection, 'turn-1') + const sending = send(adapter, 'client-late-echo', 1_700_000_000_100) + await openAfterTurnStarts(connection, 1, () => startTurn(connection, 'turn-1')) + await sending connection.handlers.onNotification?.('turn/completed', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-1' } diff --git a/src/main/codex/codex-structured-dispatch-boundary.test.ts b/src/main/codex/codex-structured-dispatch-boundary.test.ts index 5d4f0912bcc..5ad36626c46 100644 --- a/src/main/codex/codex-structured-dispatch-boundary.test.ts +++ b/src/main/codex/codex-structured-dispatch-boundary.test.ts @@ -6,41 +6,25 @@ import { fakeCodexAppServer } from './codex-structured-dispatch-test-support' -it('checks continuation authority after process capture and before writing turn/start', async () => { - const capturing = Promise.withResolvers() - const captured = Promise.withResolvers() +it('checks continuation authority before writing turn/start', async () => { const codex = fakeCodexAppServer() - const adapter = await acquiredCodexAdapter({ - codex, - settlements: [], - captureTurnProcesses: () => { - capturing.resolve() - return captured.promise - } - }) - let superseded = false + const adapter = await acquiredCodexAdapter({ codex, settlements: [] }) const beforeDispatch = vi.fn(async () => { - if (superseded) { - throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') - } + throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) try { - const result = adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'continuation', - body: CODEX_TEST_USER_MESSAGE, - fence: 7, - beforeDispatch - }) - const verdict = result.catch((error: unknown) => error) - await capturing.promise - superseded = true - captured.resolve(null) - expect(await verdict).toBeInstanceOf(AgentSessionPreDispatchError) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'continuation', + body: CODEX_TEST_USER_MESSAGE, + fence: 7, + beforeDispatch + }) + ).rejects.toBeInstanceOf(AgentSessionPreDispatchError) expect(beforeDispatch).toHaveBeenCalledOnce() expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/start')).toBe(false) } finally { - captured.resolve(null) await adapter.closeAll() } }) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index 94826c1df12..a6791e5e788 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -126,3 +126,44 @@ describe('readCodexDispatchEcho', () => { ).toBeNull() }) }) + +describe('the turn Codex answered a send into but has not opened', () => { + const NONE_OPEN = new Set() + + it('is the turn the latest armed send was answered into', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2') + }) + + it('is none once Codex opened that turn', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull() + }) + + it('is none once that turn ended, even with its send still armed for an echo', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + // A completed end leaves its unechoed send armed. + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([]) + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() + }) + + it('is none for a send not yet answered, or answered on another thread', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-2', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index c1a020b2c1f..ef84501adcb 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -38,9 +38,13 @@ export type CodexDispatchEchoes = { * read after it; a send that end settles is no longer armed. */ bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null + /** The turn the latest armed send was answered into that is neither in `openTurnIds` nor ended: + * one Codex has picked for the send but not opened. */ + answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it - * completed, which echoes its pending input first, so one it never echoed waits for recovery. + * completed, which echoes its pending input first, so one it never echoed waits for recovery. An + * interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history. */ endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] /** Submission origin for this exact send, retained until its echo settles it. */ @@ -52,7 +56,10 @@ export type CodexDispatchEchoes = { } export function createCodexDispatchEchoes(): CodexDispatchEchoes { - const armed = new Map() + const armed = new Map< + string, + { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } + >() const endedTurns = new Map() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) @@ -79,14 +86,23 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { if (!entry) { return null } - const turn = turnKey(threadId, turnId) - entry.turn = turn - const end = endedTurns.get(turn) ?? null + entry.turn = { threadId, turnId } + const end = endedTurns.get(turnKey(threadId, turnId)) ?? null if (end && settles(end)) { armed.delete(clientMessageId) } return end }, + answeredUnopenedTurn: (threadId, openTurnIds) => { + const answered = [...armed.values()].flatMap(({ turn }) => + turn?.threadId === threadId && + !openTurnIds.has(turn.turnId) && + !endedTurns.has(turnKey(threadId, turn.turnId)) + ? [turn.turnId] + : [] + ) + return answered.at(-1) ?? null + }, endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -101,7 +117,9 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { return [] } const settled = [...armed].flatMap(([clientMessageId, entry]) => - entry.turn === turn ? [clientMessageId] : [] + entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn + ? [clientMessageId] + : [] ) for (const clientMessageId of settled) { armed.delete(clientMessageId) diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 566922d7422..2576c775e21 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,3 +1,4 @@ +import { expect, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -10,6 +11,7 @@ import type { } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' export const CODEX_TEST_THREAD_ID = 'thread-abc' @@ -82,7 +84,7 @@ export async function acquiredCodexAdapter(input: { codex: ReturnType settlements: LateSettlement[] sink?: StructuredAgentSessionEventSink - captureTurnProcesses?: CodexStructuredSessionAdapterDeps['captureTurnProcesses'] + requestTimeoutMs?: number }): Promise { const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -94,9 +96,9 @@ export async function acquiredCodexAdapter(input: { }), openConnection: input.codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, - captureTurnProcesses: input.captureTurnProcesses ?? (async () => null), now: () => 1_700_000_000_500, - onDispatchSettledLate: (settlement) => input.settlements.push(settlement) + onDispatchSettledLate: (settlement) => input.settlements.push(settlement), + ...(input.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: input.requestTimeoutMs }) }) const identity: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -130,9 +132,50 @@ export function echoUserMessage( }) } -export function startTurn(connection: FakeConnection, turnId: string): void { +export function startTurn(connection: Pick, turnId: string): void { connection.handlers.onNotification?.('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: turnId } }) } + +/** Runs `open` once `count` sends reached Codex, which opens a turn only after it answers. */ +export async function openAfterTurnStarts( + connection: Pick, + count: number, + open: () => void +): Promise { + await vi.waitFor(() => + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(count) + ) + open() +} + +/** An acquired adapter over a fake Codex that keeps Codex's own turn bookkeeping. */ +export async function codexTurnLifecycleRig(options: { requestTimeoutMs?: number } = {}) { + const codex = fakeCodexAppServer() + const notify = (method: string, params: unknown): void => + codex.connections.at(-1)?.handlers.onNotification?.(method, params) + const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => notify) + Object.assign(codex.routes, turns.routes) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements, ...options }) + const send = (clientMessageId: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) + const interrupts = () => + codex.connections[0]!.calls.filter((call) => call.method === 'turn/interrupt') + return { codex, turns, adapter, send, settlements, notify, interrupts } +} + +/** The promise's value, or `held` when it has not settled `withinMs` after every earlier task. */ +export function settledWithin(promise: Promise, withinMs = 50): Promise { + return Promise.race([ + promise, + new Promise<'held'>((resolve) => setTimeout(() => resolve('held'), withinMs)) + ]) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 3029bff280b..73f279bd2c0 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { USER_MESSAGE, adapterFor, + answerWithOpenedTurn, fakeCodex, identityFor, type Route @@ -19,9 +20,9 @@ describe('Codex structured Fast mode dispatch', () => { } ], nextCursor: null - }), - 'turn/start': () => ({ turn: { id: 'turn-fast' } }) + }) }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-fast') const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), @@ -43,7 +44,8 @@ describe('Codex structured Fast mode dispatch', () => { }) it('uses Standard on the first turn after acquisition with Fast explicitly off', async () => { - const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-standard' } }) }) + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-standard') const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), @@ -91,10 +93,8 @@ describe('Codex structured Fast mode dispatch', () => { ], nextCursor: null })) - const codex = fakeCodex({ - 'model/list': listModels, - 'turn/start': () => ({ turn: { id: 'turn-recovered' } }) - }) + const codex = fakeCodex({ 'model/list': listModels }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-recovered') const adapter = adapterFor(codex) await expect( adapter.acquire({ diff --git a/src/main/codex/codex-structured-item-stream-contracts.ts b/src/main/codex/codex-structured-item-stream-contracts.ts index ebbd05731d4..733a81455c3 100644 --- a/src/main/codex/codex-structured-item-stream-contracts.ts +++ b/src/main/codex/codex-structured-item-stream-contracts.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journa import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { codexJournalItem, CodexThreadItem } from './codex-structured-item-translation' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexItemStreamDeps = { sink: StructuredAgentSessionEventSink @@ -13,7 +13,7 @@ export type CodexItemStreamDeps = { turnId: string | null, item: CodexThreadItem ) => AgentJournalItemIdentity - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution coalesceMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 03085bf6913..306fcf27619 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,4 +1,8 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import { CodexItemStreamRetention } from './codex-item-stream-retention' import { appendCodexItemAndPublish } from './codex-structured-journal-sink' @@ -51,12 +55,14 @@ export function createCodexStructuredItemStreams( const states = new CodexItemStreamRetention(deps.maxMetadataBytes) const checkpointLengths = new Map() const pendingCheckpoints = new Set() - // Which thread and turn produced each stream, resolved to linkage per append + // Which thread and turn produced each stream, resolved to its attribution per append // so a parent learned after the first checkpoint still reaches the row. const producers = new Map() - const linkageOf = (key: string) => { + const attributionOf = (key: string): AgentJournalRowAttribution => { const producer = producers.get(key) - return producer ? deps.linkageFor(producer.threadId, producer.turnId) : {} + return producer + ? deps.attributionFor(producer.threadId, producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } // Patch updates are authoritative item snapshots. Keep the latest rejected // snapshot until the journal admits it; unlike streamed deltas, there is no @@ -114,7 +120,7 @@ export function createCodexStructuredItemStreams( } return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, { coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}`, - ...linkageOf(key) + ...attributionOf(key) }).accepted } @@ -191,7 +197,7 @@ export function createCodexStructuredItemStreams( deps.sink, pending.identity, pending.body, - linkageOf(key) + attributionOf(key) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-compactions.test.ts b/src/main/codex/codex-structured-journal-compactions.test.ts index b86cea2725d..4da87a0c75f 100644 --- a/src/main/codex/codex-structured-journal-compactions.test.ts +++ b/src/main/codex/codex-structured-journal-compactions.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -82,7 +83,7 @@ describe('compaction provider generation compatibility', () => { if (stage === 'append' && reject) { return { accepted: false, reason: 'backpressure' } } - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return { accepted: true } } sink.tryPublish = () => { diff --git a/src/main/codex/codex-structured-journal-compactions.ts b/src/main/codex/codex-structured-journal-compactions.ts index 9aa2e8f4cf1..61743c1996c 100644 --- a/src/main/codex/codex-structured-journal-compactions.ts +++ b/src/main/codex/codex-structured-journal-compactions.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto' -import { isCodexCompactionComplete } from '../native-chat/agent-session-wire/structured-session-compaction' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -8,7 +7,16 @@ import { import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { readRecord } from './codex-item-field-readers' +import type { CodexRowAttribution } from './codex-subagent-linkage' + +function isCodexCompactionComplete(method: string, params: unknown): boolean { + return ( + method === 'thread/compacted' || + (method === 'item/completed' && + readRecord(readRecord(params).item).type === 'contextCompaction') + ) +} export class CodexJournalCompactions { private readonly turns = new Map() @@ -16,7 +24,9 @@ export class CodexJournalCompactions { constructor( private readonly sink: StructuredAgentSessionEventSink, private readonly activeTurn: (threadId: string) => string | null, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution, + /** Evidence for a conversation command the turn carries; its marker is the command's result. */ + private readonly compacted: (turnId: string) => void ) {} handle(event: { @@ -31,6 +41,7 @@ export class CodexJournalCompactions { if (!turnId) { return null } + this.compacted(turnId) // Collapse compactions within a thread/turn; the canonical item replaces its legacy fallback. const key = createHash('sha256') .update(JSON.stringify([event.threadId, turnId])) @@ -44,7 +55,7 @@ export class CodexJournalCompactions { this.sink, { provider: 'orca', clientMessageId: `codex-compaction:${key}` }, { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, - this.linkageFor(event.threadId, turnId) + this.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 28b5583a9b4..74cf5013aff 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -4,6 +4,7 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-sessio import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -34,6 +35,13 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + /** Before Orca sends a conversation command: the next primary turn carries it out, and that + * turn's end is the command's. */ + beginCommand: (command: StructuredAgentSessionCommandRun) => void + /** The command was never taken. */ + forgetCommand: (turnId: string) => void + /** The provider turn a Stop naming `turnId` interrupts; undefined while a command has none. */ + commandProviderTurnId: (turnId: string) => string | undefined cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index 7d9f54462fc..a8b400a6d93 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -12,7 +12,8 @@ import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { CodexRowAttribution } from './codex-subagent-linkage' const OVERFLOW_BUCKET = '__codex-generic-overflow__' /** `producer` is absent only on the overflow bucket, which pools every thread's @@ -59,7 +60,7 @@ export class CodexJournalGenericFrames { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null ) { @@ -102,10 +103,10 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame:codex:${this.fallbackSequence}` } - const linkage = this.deps.linkageFor(threadId, frameTurnId) + const attribution = this.deps.attributionFor(threadId, frameTurnId) const admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(identity, translated.body, linkage) - : (this.deps.sink.appendItem(identity, translated.body, linkage), CODEX_JOURNAL_ADMITTED) + ? this.deps.sink.tryAppendItem(identity, translated.body, attribution) + : (this.deps.sink.appendItem(identity, translated.body, attribution), CODEX_JOURNAL_ADMITTED) if (!admission.accepted) { this.fallbackSequence -= 1 return admission @@ -139,11 +140,12 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame-suppressed:codex:${bucket}` } + // A summary across evicted turns names no producer and belongs to no turn. const options = { coalescingKey: `provider-frame-suppressed:codex:${bucket}`, ...(summary.producer - ? this.deps.linkageFor(summary.producer.threadId, summary.producer.turnId) - : {}) + ? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, { kind: 'status', text }, options) diff --git a/src/main/codex/codex-structured-journal-goal-admission.test.ts b/src/main/codex/codex-structured-journal-goal-admission.test.ts index b0a9e50ba2c..f2a3a9b166c 100644 --- a/src/main/codex/codex-structured-journal-goal-admission.test.ts +++ b/src/main/codex/codex-structured-journal-goal-admission.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -137,7 +138,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const update = (goal: Record = {}) => goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) }) const clear = () => @@ -178,7 +179,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const send = (threadId: string) => goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() }) @@ -211,7 +212,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 4609a06fb0d..6df0ba01575 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -130,7 +131,9 @@ describe('codex goal lifecycle resume', () => { it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { const journal = goalJournal() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) expect( resumed.handle({ @@ -151,7 +154,9 @@ describe('codex goal lifecycle resume', () => { it('does not revisit durable history for accounting-only updates', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await journal.drained() const visits = journal.visits() @@ -176,7 +181,9 @@ describe('codex goal lifecycle resume', () => { it('rebuilds dedupe state after the journal epoch is replaced', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) @@ -198,7 +205,9 @@ describe('codex goal lifecycle resume', () => { it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => { const journal = goalJournal() journal.seedProviderItems(10_000) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const threadCount = MAX_CODEX_GOAL_THREADS + 1 const sendRound = () => { for (let index = 0; index < threadCount; index += 1) { @@ -227,7 +236,9 @@ describe('codex goal lifecycle resume', () => { const journal = goalJournal() journal.seedProviderItems(10_000) journal.unbind() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { goals.handle({ @@ -249,7 +260,9 @@ describe('codex goal lifecycle resume', () => { it('retries a journal-derived transition after lifecycle backpressure', async () => { const journal = goalJournal({ watermarks: { maxLifecycleQueuedOperations: 1 } }) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) journal.unbind() expect( @@ -317,7 +330,9 @@ describe('codex goal lifecycle resume', () => { }) } - const prior = new CodexJournalGoals(journal.sink, () => ({})) + const prior = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (const state of scenario.beforeResume) { send(prior, state) } @@ -329,7 +344,9 @@ describe('codex goal lifecycle resume', () => { prior.dispose() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) resumed.handle({ threadId: THREAD, method: scenario.resumed.method, diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index e6f96ad5503..4f827600577 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -9,7 +10,7 @@ import type { import { currentAgentSessionThreadGoal } from '../../shared/agent-session-thread-goal' import type { AgentSessionHistoryPage } from '../../shared/agent-session-wire' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' @@ -89,14 +90,14 @@ function journalSink(journal: AgentSessionJournal) { describe('codex goal accounting revisions', () => { it('revises the goal row in place: one visible row, pinned sequence, fresh counters', async () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-revision-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( { provider: 'orca', clientMessageId: 'earlier' }, { kind: 'status', text: 'Context compacted' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { sink, drained, subscribe, published } = journalSink(journal) - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await drained() @@ -104,7 +105,7 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'later' }, { kind: 'status', text: 'Something after the goal' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribe() @@ -158,16 +159,20 @@ describe('codex goal accounting revisions', () => { it('refreshes the row from a resume snapshot of the same goal', async () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-resume-revision-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const first = journalSink(journal) - const prior = new CodexJournalGoals(first.sink, () => ({})) + const prior = new CodexJournalGoals(first.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) prior.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await first.drained() prior.dispose() const [created] = journal.snapshot().items const second = journalSink(journal) - const resumed = new CodexJournalGoals(second.sink, () => ({})) + const resumed = new CodexJournalGoals(second.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) // Only a few seconds more: a resume snapshot still refreshes, since no live tick follows. const snapshot = goalFrame({ timeUsedSeconds: 3, updatedAt: 1789067991 }) resumed.handle({ threadId: THREAD, method: 'thread/goal/updated', params: snapshot }) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts index ff234c7d5fb..053376e30e0 100644 --- a/src/main/codex/codex-structured-journal-goal-rows.test.ts +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -35,8 +36,11 @@ function frames(): { }, publish: vi.fn() } as unknown as StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) - const generic = new CodexJournalGenericFrames({ sink, linkageFor: () => ({}) }, () => null) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + const generic = new CodexJournalGenericFrames( + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, + () => null + ) return { rows, frames: { diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts index 681a483c12f..d73b163765e 100644 --- a/src/main/codex/codex-structured-journal-goals.ts +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -24,7 +24,7 @@ import { } from './codex-structured-journal-contracts' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' import { appendCodexLifecycleTransition } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' type GoalAccounting = { key: string; timeUsedSeconds: number; tokenBudget: number | null } @@ -70,7 +70,7 @@ export class CodexJournalGoals { constructor( private readonly sink: StructuredAgentSessionEventSink, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution ) {} handle(event: { @@ -132,7 +132,7 @@ export class CodexJournalGoals { event.method === 'thread/goal/cleared' ), // A goal belongs to its thread, not to one run of it, so no turn is named. - this.linkageFor(event.threadId, null) + this.attributionFor(event.threadId, null) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 6717f3edabe..4e6ebe8e5e0 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -1,7 +1,7 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement' import { @@ -31,7 +31,7 @@ import type { CodexActiveJournalItem } from './codex-structured-journal-settleme import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -44,7 +44,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' - > & { maxMetadataBytes?: number; linkageFor: CodexRowLinkage }, + > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void ) { @@ -56,7 +56,7 @@ export class CodexJournalItems { maxMetadataBytes: deps.maxMetadataBytes, turnIdFor: (threadId, params) => readCodexTurnId(params) ?? this.activeTurn(threadId), identityFor: (threadId, turnId, item) => this.identityFor(threadId, turnId, item), - linkageFor: deps.linkageFor + attributionFor: deps.attributionFor }) } @@ -122,7 +122,7 @@ export class CodexJournalItems { event.method, identity, translated, - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return { handled: true, admission } @@ -151,17 +151,22 @@ export class CodexJournalItems { method: string, identity: AgentJournalItemIdentity, translated: ReturnType, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (!translated.body) { return CODEX_JOURNAL_ADMITTED } if (method === 'item/completed') { - const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body, linkage) + const admission = appendCodexLifecycleItem( + this.deps.sink, + identity, + translated.body, + attribution + ) return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission } const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {} - const appendOptions = { ...options, ...linkage } + const appendOptions = { ...options, ...attribution } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, translated.body, appendOptions) : (this.deps.sink.appendItem(identity, translated.body, appendOptions), @@ -235,7 +240,7 @@ export class CodexJournalItems { this.deps.sink, evicted.identity, evictedActiveBody(translated), - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 96c58f9b8b7..9c145915fe5 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -20,7 +20,7 @@ import { } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' import { journalLifecycleItemMutation } from '../native-chat/agent-session-journal/journal-row-builders' type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } @@ -30,7 +30,7 @@ export class CodexJournalPrompts { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly detailFor: (threadId: string, itemId: string) => string | null, private readonly activeTurn: (threadId: string) => string | null @@ -118,7 +118,7 @@ export class CodexJournalPrompts { ) const mutations = group.flatMap(([, prompt]) => { const body = cancelledJournalPromptBody(prompt.body) - const producer = this.deps.linkageFor(prompt.threadId, prompt.turnId) + const producer = this.deps.attributionFor(prompt.threadId, prompt.turnId) return body ? [journalLifecycleItemMutation(producer, prompt.identity, body)] : [] }) const admission = appendCodexLifecycleMutations( @@ -152,7 +152,7 @@ export class CodexJournalPrompts { )}:${encodeURIComponent(event.promptKey)}`, items, // A child's approval arrives on the child's own thread, so it names the asker. - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) } @@ -170,7 +170,7 @@ export class CodexJournalPrompts { this.deps.sink, evicted.identity, cancelled, - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index 8ce2591f57c..7c636545daf 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold' -import { nativeChatRowTurnKeys } from '../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' import type { NativeChatMessage } from '../../shared/native-chat-types' import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection' import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing' +import { JournalDerivedTurnScope } from '../native-chat/agent-session-journal/journal-derived-turn-scope' import { createCodexJournalTranslator } from './codex-structured-journal-translation' const THREAD_ID = 'thread-abc' @@ -29,19 +30,25 @@ function historicalTurn(index: number): Record { } } -/** What the journal hands a reader after a restore: rows in append order. */ -function restoredJournal(turnCount: number): AgentJournalRenderItem[] { +/** What the journal hands a reader after a restore: rows in append order. With `statesScope`, each + * row carries the turn scope the journal gives a row the translator writes without one. */ +function restoredJournal(turnCount: number, statesScope: boolean): AgentJournalRenderItem[] { const items: AgentJournalRenderItem[] = [] + const derived = new JournalDerivedTurnScope() const translator = createCodexJournalTranslator({ sink: { - appendItem: (identity, body) => { + appendItem: (identity, body, options) => { const sequence = items.length + 1 + const itemId = agentJournalItemKey(identity) + const turnScope = options.turnScope ?? derived.scopeFor(body) + derived.observe(itemId, true, undefined, body) items.push({ - itemId: agentJournalItemKey(identity), + itemId, revision: 1, body, sequence, - observedAt: sequence + observedAt: sequence, + ...(statesScope ? { turnScope } : {}) }) }, appendTombstone: () => {}, @@ -56,43 +63,51 @@ function restoredJournal(turnCount: number): AgentJournalRenderItem[] { } describe('grouping a Codex thread restored from full history', () => { - it('keeps each turn with its own rows and folds each to its own answer', () => { - const items = restoredJournal(3) - const bars = selectStructuredAgentTurnBars(items, [], null) - const messages = items - .map(projectStructuredItemToNativeChat) - .filter((message): message is NativeChatMessage => message !== null) - const turnKeys = nativeChatRowTurnKeys(messages, bars.turnKeysByItemId) - const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + 'keeps each turn with its own rows and folds each to its own answer on a host that %s', + (_host, statesScope) => { + const items = restoredJournal(3, statesScope) + const bars = selectStructuredAgentTurnBars(items, [], null) + const messages = items + .map(projectStructuredItemToNativeChat) + .filter((message): message is NativeChatMessage => message !== null) + const { turnKeys } = nativeChatTurnMembership(messages, { items, submissions: [] }) + const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` - expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ - ['user', opener(1)], - ['assistant', opener(1)], - ['assistant', opener(1)], - ['user', opener(2)], - ['assistant', opener(2)], - ['assistant', opener(2)], - ['user', opener(3)], - ['assistant', opener(3)], - ['assistant', opener(3)] - ]) - expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) - - const { foldedRows } = nativeChatTurnFold({ - rows: messages.map((message, index) => ({ - turnKey: turnKeys[index], - role: message.role, - rendersProse: true, - outlivesTurn: false - })), - settledTurnKeys: new Set(bars.settledTurns.keys()), - expandedTurnKeys: new Set() - }) - const visible = messages.filter((_, index) => !foldedRows.has(index)) - expect(visible.map((message) => message.blocks)).toEqual( - ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ - { type: 'text', text } + expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ + ['user', opener(1)], + ['assistant', opener(1)], + ['assistant', opener(1)], + ['user', opener(2)], + ['assistant', opener(2)], + ['assistant', opener(2)], + ['user', opener(3)], + ['assistant', opener(3)], + ['assistant', opener(3)] ]) - ) - }) + expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) + + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => ({ + turnKey: turnKeys[index], + role: message.role, + rendersProse: true, + outlivesTurn: false, + reportsFailure: false, + reportsCompaction: false + })), + settledTurnKeys: new Set(bars.settledTurns.keys()), + expandedTurnKeys: new Set() + }) + const visible = messages.filter((_, index) => !foldedRows.has(index)) + expect(visible.map((message) => message.blocks)).toEqual( + ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ + { type: 'text', text } + ]) + ) + } + ) }) diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 39d52e6b047..a3f4c7f5599 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,7 +1,8 @@ -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import { journalLifecycleItemMutation, @@ -26,7 +27,7 @@ import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { appendCodexLifecycleMutations } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexActiveJournalItem = { threadId: string @@ -53,9 +54,10 @@ export function settleCodexJournalSession(input: { currentTurnIds: ReadonlyMap> primaryThreadId: string | null ordinals: CodexTurnOrdinals - /** Terminal lifecycle for a turn the provider left running when it ended. */ - settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle - linkageFor: CodexRowLinkage + /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a + * conversation command claimed, whose record the host settles. */ + settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null + attributionFor: CodexRowAttribution }): StructuredAgentSessionSinkAdmission { // Rows from every thread settle in this one batch, so each names its own producer. const mutations: JournalLifecycleMutationInput[] = [] @@ -67,13 +69,13 @@ export function settleCodexJournalSession(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } } for (const prompt of input.pendingPrompts.values()) { const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } } for (const [threadId, turnIds] of input.currentTurnIds) { @@ -81,11 +83,15 @@ export function settleCodexJournalSession(input: { continue } for (const turnId of turnIds) { - mutations.push({ - kind: 'item', - identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), - body: codexTurnLifecycleBody(input.settledTurnLifecycle(threadId, turnId)) - }) + const turnLifecycle = input.settledTurnLifecycle(threadId, turnId) + if (turnLifecycle) { + mutations.push({ + kind: 'item', + identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), + body: codexTurnLifecycleBody(turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } turnOrdinalsToForget.push({ threadId, turnId }) } } @@ -114,7 +120,9 @@ export function settleCodexJournalTurn(input: { activeItems: Map pendingPrompts?: Map clearPromptTurn?: (threadId: string, turnId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + /** The end of a conversation command the turn carried, which settles with it. */ + commandEnd?: readonly JournalLifecycleMutationInput[] }): StructuredAgentSessionSinkAdmission { const mutations: JournalLifecycleMutationInput[] = [] const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] @@ -133,7 +141,7 @@ export function settleCodexJournalTurn(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) } @@ -143,7 +151,7 @@ export function settleCodexJournalTurn(input: { } const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } pendingPromptsToForget.push(key) } @@ -152,9 +160,11 @@ export function settleCodexJournalTurn(input: { mutations.push({ kind: 'item', identity: codexTurnLifecycleIdentity(input.sessionId, input.turnId), - body: codexTurnLifecycleBody(input.turnLifecycle) + body: codexTurnLifecycleBody(input.turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } + mutations.push(...(input.commandEnd ?? [])) const admission = appendCodexLifecycleMutations( input.sink, `turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`, @@ -176,11 +186,11 @@ export function settleCodexJournalTurn(input: { /** A settled item or prompt, naming its producer: the settlement can be the row's first write. */ function settledRow( - linkageFor: CodexRowLinkage, + attributionFor: CodexRowAttribution, row: { threadId: string; turnId: string | null; identity: AgentJournalItemIdentity }, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return journalLifecycleItemMutation(linkageFor(row.threadId, row.turnId), row.identity, body) + return journalLifecycleItemMutation(attributionFor(row.threadId, row.turnId), row.identity, body) } function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null { diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 95a27702365..0823e5679cf 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -1,10 +1,10 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission @@ -35,7 +35,11 @@ export function appendCodexLifecycleMutations( } else { for (const mutation of chunk) { if (mutation.kind === 'item') { - const options = { lifecycle: true, ...mutation.linkage } + const options = { + lifecycle: true, + ...mutation.linkage, + turnScope: mutation.turnScope + } if (sink.tryAppendItem) { admission = sink.tryAppendItem(mutation.identity, mutation.body, options) if (!admission.accepted) { @@ -74,7 +78,7 @@ export function appendCodexItemAndPublish( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission { const admission = sink.tryAppendItem ? sink.tryAppendItem(identity, body, options) @@ -95,9 +99,9 @@ export function appendCodexLifecycleItem( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { - const options = { lifecycle: true, ...linkage } + const options = { lifecycle: true, ...attribution } if (sink.tryAppendItem) { return criticalAdmission(sink.tryAppendItem(identity, body, options)) } @@ -110,14 +114,14 @@ export function appendCodexLifecycleTransition( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (sink.tryAppendLifecycleTransition) { return criticalAdmission( - sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, linkage) + sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, attribution) ) } - const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, linkage) + const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, attribution) return admission.accepted ? publishCodexLifecycle(sink) : admission } @@ -136,7 +140,7 @@ export function admitCodexLifecycleItems( sink: StructuredAgentSessionEventSink, settlementId: string, items: readonly Pick[], - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (items.length === 0) { return { accepted: false, reason: 'untranslated' } @@ -145,14 +149,14 @@ export function admitCodexLifecycleItems( const admission = criticalAdmission( sink.tryAppendLifecycleBatch( settlementId, - items.map((item) => journalLifecycleItemMutation(linkage, item.identity, item.body)), + items.map((item) => journalLifecycleItemMutation(attribution, item.identity, item.body)), { lifecycle: true } ) ) return admission.accepted ? publishCodexLifecycle(sink) : admission } for (const item of items) { - const admission = appendCodexLifecycleItem(sink, item.identity, item.body, linkage) + const admission = appendCodexLifecycleItem(sink, item.identity, item.body, attribution) if (!admission.accepted) { return admission } diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 117f204e963..69e28d67ec4 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { selectStructuredAgentTurnActivity } from '../../shared/native-chat-turn-activity' -import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-latest-request' import { isStructuredAgentSessionThinking, statusStructuredAgentSessionToolCall @@ -19,6 +19,7 @@ import type { AgentSessionJournal } from '../native-chat/agent-session-journal/j import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexThreadItem } from './codex-thread-item-identity' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const SESSION = 'session-codex-children' const PARENT = 'thread-parent' @@ -42,7 +43,7 @@ async function openJournal(root: string): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: PARENT } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) } diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index ab0e602e955..344d99e03b4 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -551,7 +551,8 @@ describe('codex journal translation', () => { userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, startedAt: expect.any(Number), completedAt: expect.any(Number) - } + }, + turnScope: { kind: 'thread' } } ] } diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 3116a329c97..a853ca2bfd8 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -2,7 +2,10 @@ import type { AgentJournalTurnLifecycle, AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -27,7 +30,10 @@ import { readCodexTurnId, readCodexTurnStatus } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' +import type { CodexJournalCommandTurn } from './codex-journal-command-turn' +import { readRecord, readString } from './codex-item-field-readers' +import { recordCodexCommandTurnClaim } from './codex-command-turn-claim' type TurnBoundaryEvent = { sessionId: string @@ -59,7 +65,8 @@ export class CodexJournalTurnBoundaries { clearPromptTurn?: (threadId: string, turnId: string) => void flushSuppression: () => CodexJournalTranslationAdmission resetActivity: (threadId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + commands: CodexJournalCommandTurn now?: () => number } ) {} @@ -73,15 +80,20 @@ export class CodexJournalTurnBoundaries { return { accepted: false, reason: 'backpressure' } } const startedAt = this.receiptTime(event) - const admission = publishCodexTurnLifecycle({ - sink: this.deps.sink, - primaryThreadId: this.deps.primaryThreadId(), - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - state: 'running', - startedAt - }) + const command = + event.threadId === this.deps.primaryThreadId() ? this.deps.commands.claim(turnId) : null + // The command's turn is the record: this turn writes none, and its rows join the command's. + const admission = command + ? recordCodexCommandTurnClaim(this.deps.sink, agentJournalItemKey(command.identity), turnId) + : publishCodexTurnLifecycle({ + sink: this.deps.sink, + primaryThreadId: this.deps.primaryThreadId(), + sessionId: event.sessionId, + threadId: event.threadId, + turnId, + state: 'running', + startedAt + }) if (admission.accepted) { this.deps.activeTurns.remember( event.threadId, @@ -156,15 +168,20 @@ export class CodexJournalTurnBoundaries { // turn boundary is no evidence contact was lost. Only `settleSession` may // write `unverifiable`. const status = readCodexTurnStatus(event.params) - const turnLifecycle = - event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, { - state: codexTurnLifecycleState(status), - outcome: codexTurnOutcome(status), - completedAt: this.receiptTime(event), - durationMs: readCodexTurnDurationMs(event.params) - }) - : null + const completedAt = this.receiptTime(event) + const commandEnd = this.deps.commands.end(turnId, { + status, + error: readString(readRecord(readRecord(readRecord(event.params).turn).error), 'message'), + completedAt + }) + const turnLifecycle = this.ownsRecord(event.threadId, turnId) + ? this.settled(event.threadId, turnId, { + state: codexTurnLifecycleState(status), + outcome: codexTurnOutcome(status), + completedAt, + durationMs: readCodexTurnDurationMs(event.params) + }) + : null const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( event.threadId, @@ -180,7 +197,8 @@ export class CodexJournalTurnBoundaries { activeItems: this.deps.items.activeItems, pendingPrompts: this.deps.pendingPrompts, ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}), - linkageFor: this.deps.linkageFor + attributionFor: this.deps.attributionFor, + commandEnd }) if (admission.accepted) { if (turnLifecycle) { @@ -193,6 +211,7 @@ export class CodexJournalTurnBoundaries { } this.deps.items.ordinals.forgetTurn(event.threadId, turnId) this.deps.activeTurns.forget(event.threadId, turnId) + this.deps.commands.settled(turnId) this.deps.resetActivity(event.threadId) } return admission @@ -216,9 +235,15 @@ export class CodexJournalTurnBoundaries { } } + /** Whether this translator writes the turn's record: a primary turn no command claimed. */ + ownsRecord(threadId: string, turnId: string): boolean { + return threadId === this.deps.primaryThreadId() && !this.deps.commands.isCarrying(turnId) + } + clear(): void { this.deps.activeTurns.clear() this.recentTurns.clear() + this.deps.commands.clear() } private receiptTime(event: TurnBoundaryEvent): number { diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 6fb589caf4e..f9714bbe062 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -11,7 +11,7 @@ import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../../shared/agent-session-turn-record' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventSink @@ -221,7 +221,7 @@ describe('codex turn lifecycle rows', () => { providerHandle: { kind: 'codex', threadId: THREAD_ID } }, now: () => 9_000, - journalDir: join(root, SESSION_ID) + stateDirectory: join(root, SESSION_ID) }) const deferred = createDeferredStructuredAgentSessionEventSink() const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 52dca66a35b..349233a32d1 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -1,9 +1,10 @@ -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem, - AgentJournalTurnLifecycle, - AgentJournalTurnLifecycleState, - AgentJournalTurnOutcome +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem, + type AgentJournalTurnLifecycle, + type AgentJournalTurnLifecycleState, + type AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' @@ -109,6 +110,7 @@ export function publishCodexTurnLifecycle(input: { // The running row's `ts` is the host's turn-start receipt so clients can anchor a live counter. const appendOptions = { lifecycle: true, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(input.state === 'running' && input.startedAt !== undefined ? { observedAt: input.startedAt } : {}) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 72c1026452d..b13a7212142 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -1,6 +1,6 @@ // The translator's writers, built once. Split out so the translator reads as // routing, and so one place shows that every writer is handed the same -// producer resolver: the roster's, which knows each child thread. +// row attribution: the roster's producer, which knows each child thread, and the turn scope. import { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' @@ -9,31 +9,50 @@ import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' +import { CodexJournalTurnScopes } from './codex-journal-turn-scopes' +import { CodexJournalCommandTurn } from './codex-journal-command-turn' import { CodexSubagentRoster } from './codex-subagent-roster' +import type { CodexRowAttribution } from './codex-subagent-linkage' export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) { const activeTurns = new CodexJournalActiveTurns() const activeTurn = (threadId: string): string | null => activeTurns.current(threadId) + const primaryThreadId = (): string | null => deps.primaryThreadId?.() ?? null + const commands = new CodexJournalCommandTurn() + const turnScopes = new CodexJournalTurnScopes({ + sessionId: deps.sessionId, + primaryThreadId, + activeTurn, + commandScope: (turnId) => commands.scopeFor(turnId) + }) const subagents = new CodexSubagentRoster({ sink: deps.sink, - primaryThreadId: () => deps.primaryThreadId?.() ?? null, + primaryThreadId, activeTurn, + turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId), ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) }) const { linkageFor } = subagents.linkage - const producerDeps = { ...deps, linkageFor } + const attributionFor: CodexRowAttribution = (threadId, turnId) => ({ + ...linkageFor(threadId, turnId), + turnScope: turnScopes.scopeFor(threadId, turnId) + }) + const producerDeps = { ...deps, attributionFor } const genericFrames = new CodexJournalGenericFrames(producerDeps, activeTurn) const items = new CodexJournalItems(producerDeps, activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) return { activeTurns, + commands, subagents, - linkageFor, + attributionFor, genericFrames, items, - compactions: new CodexJournalCompactions(deps.sink, activeTurn, linkageFor), - goals: new CodexJournalGoals(deps.sink, linkageFor), + compactions: new CodexJournalCompactions(deps.sink, activeTurn, attributionFor, (turnId) => + commands.compacted(turnId) + ), + goals: new CodexJournalGoals(deps.sink, attributionFor), prompts: new CodexJournalPrompts( producerDeps, (threadId, itemId) => items.detailFor(threadId, itemId), diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fadab8b4589..8e05506509a 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -35,8 +35,17 @@ export { export function createCodexJournalTranslator( deps: CodexJournalTranslatorDeps ): CodexJournalTranslator { - const { activeTurns, subagents, linkageFor, genericFrames, items, compactions, goals, prompts } = - createCodexJournalTranslatorWriters(deps) + const { + activeTurns, + commands, + subagents, + attributionFor, + genericFrames, + items, + compactions, + goals, + prompts + } = createCodexJournalTranslatorWriters(deps) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } let readActivity = createCodexProviderActivityReader() @@ -55,7 +64,8 @@ export function createCodexJournalTranslator( ...(deps.clearPromptTurn ? { clearPromptTurn: deps.clearPromptTurn } : {}), flushSuppression: () => genericFrames.flush(), resetActivity, - linkageFor, + attributionFor, + commands, ...(deps.now ? { now: deps.now } : {}) }) let primaryThreadStoppedRunning = false @@ -150,11 +160,13 @@ export function createCodexJournalTranslator( // The host saw the child go, not what Codex made of the turn, so the row // carries no outcome: the end is observed, the verdict is unknown. settledTurnLifecycle: (threadId, turnId) => - turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }), - linkageFor + turnBoundaries.ownsRecord(threadId, turnId) + ? turnBoundaries.settled(threadId, turnId, { + state: 'interrupted', + completedAt: event.observedAt ?? deps.now?.() ?? Date.now() + }) + : null, + attributionFor }) if (!admission.accepted) { return admission @@ -253,8 +265,14 @@ export function createCodexJournalTranslator( event.params, event.threadId ) + if (unhandled.accepted && event.method === 'error') { + commands.errorShown(event.params) + } return publishActivity(event, unhandled) }, + beginCommand: (command) => commands.begin(command), + forgetCommand: (turnId) => commands.forget(turnId), + commandProviderTurnId: (turnId) => commands.providerTurnId(turnId), cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index 4e3caaa8bc0..7011cfa0302 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -10,7 +10,7 @@ import { completedStructuredAgentTurnSeconds, selectStructuredAgentRunningTurnTiming } from '../../shared/structured-agent-session-turn-timing' -import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -18,6 +18,7 @@ const SESSION = 'session-codex-failed-turn' const THREAD = 'thread-abc' const TURN = 'turn-1' +const journals = createTrackedJournalOpener() const cleanups: (() => Promise)[] = [] afterEach(async () => { for (const cleanup of cleanups.splice(0)) { @@ -27,7 +28,7 @@ afterEach(async () => { async function session() { const root = await mkdtemp(join(tmpdir(), 'orca-codex-failed-turn-')) - const journal = await openAgentSessionJournal({ + const journal = await journals.open({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -35,14 +36,14 @@ async function session() { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind({ journal, fence: 1, publish: () => {} }) cleanups.push(async () => { deferred.close() - await journal.close() + await journals.closeAll() await rm(root, { recursive: true, force: true }) }) const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index ecdc4c9a4be..ef1f8cc426f 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -261,7 +261,7 @@ describe('Codex live prompt ownership', () => { fence: 7, prompt: { itemId: 'journal-prompt' } }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'journal-prompt', @@ -277,8 +277,7 @@ describe('Codex live prompt ownership', () => { const codex = fakeCodex({ 'turn/interrupt': () => completeTurn(codex, 'thread-child', 'child-turn') }) - const terminateTurnProcesses = vi.fn(async () => true) - const adapter = adapterFor(codex, {}, [], { terminateTurnProcesses }) + const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), fence: 7, @@ -298,7 +297,6 @@ describe('Codex live prompt ownership', () => { method: 'turn/interrupt', params: { threadId: 'thread-child', turnId: 'child-turn' } }) - expect(terminateTurnProcesses).not.toHaveBeenCalled() expect(codex.connections[0]?.closed).toBe(false) }) @@ -553,7 +551,7 @@ describe('Codex live prompt ownership', () => { expect(commit).not.toHaveBeenCalled() }) - it('does not report success when a deferred provider completion is backpressured', async () => { + it("confirms on Codex's answer and keeps the prompt's claim when the turn's end is backpressured", async () => { const recorded = lifecycleRecorder(true, false) const codex = fakeCodex({ 'turn/interrupt': () => { @@ -580,7 +578,7 @@ describe('Codex live prompt ownership', () => { fence: 7, prompt: { itemId: promptItemId } }) - ).rejects.toThrow(/deferred turn completion lifecycle was not admitted/) + ).resolves.toEqual({ cancelled: true }) const commit = vi.fn(async () => undefined) await expect( adapter.answerPrompt({ @@ -597,7 +595,7 @@ describe('Codex live prompt ownership', () => { await adapter.closeSession('session-1') }) - it('defers only the matching thread and emits its terminal event before cancel resolves', async () => { + it("publishes every thread's turn end as Codex sends it, ahead of the Stop's answer", async () => { const interruptGate = deferred() const events: CodexStructuredSessionEvent[] = [] const codex = fakeCodex({ @@ -610,18 +608,13 @@ describe('Codex live prompt ownership', () => { const adapter = await acquired(codex, {}, events) registerPrompt(adapter, codex, 'child-prompt', 'thread-child') - const cancellation = adapter - .cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'child-prompt' } - }) - .then((result) => { - expect(completionThreads(events)).toEqual([THREAD_ID, 'thread-child']) - return result - }) - await vi.waitFor(() => expect(completionThreads(events)).toEqual([THREAD_ID])) + const cancellation = adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + await vi.waitFor(() => expect(completionThreads(events)).toEqual([THREAD_ID, 'thread-child'])) interruptGate.resolve() await expect(cancellation).resolves.toEqual({ cancelled: true }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index d58f08e731d..02633874d03 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -1,9 +1,9 @@ import { AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, + type AgentSessionCancelOutcome, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { answerCodexPrompt, prepareCodexPromptAnswer, @@ -11,31 +11,84 @@ import { type CodexPreparedAnswer } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' -import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import { interruptCodexTurn } from './codex-structured-turn-cancellation' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] +/** A command's Stop interrupts the provider turn carrying it; any other turn is its own. */ +function providerTurnId(session: CodexSession, turnId: string): string | undefined { + return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId +} + +/** How long a Stop waits for Codex to open the turn it answered a send into. A close or quit queued + * behind the Stop spends this out of the eviction budget, so a full wait plus a slow provider + * close can overrun it; the next launch's recovery then settles the lease. */ +export const CODEX_STOP_TURN_OPEN_WAIT_MS = 5_000 + +/** + * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported + * started and not yet ended, which the journal can trail by a publish, or else the one Codex + * answered a send into, once it opens. + */ +async function cancelCodexConversation( + input: Parameters[0], + session: CodexSession +): Promise { + const { request, sessions, requestTimeoutMs } = input + const liveTurnId = request.resolveLiveTurnId?.() ?? null + // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. + const turnId = + liveTurnId === null + ? ([...(session.activeTurnIds ?? [])].at(-1) ?? (await openedAnsweredTurn(session))) + : providerTurnId(session, liveTurnId) + // The wait for the turn to open can outlive the session it began on. + if ( + !turnId || + sessions.get(request.sessionId) !== session || + session.ended || + session.fence !== request.fence + ) { + return { cancelled: false } + } + return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) +} + +/** The turn Codex answered a send into and has not opened yet, once it opens; null when it ends, + * the thread stops running or the child exits first, or the wait runs out. */ +async function openedAnsweredTurn(session: CodexSession): Promise { + const openTurnIds = session.activeTurnIds ?? new Set() + const answered = session.dispatchEchoes.answeredUnopenedTurn(session.threadId, openTurnIds) + if (!answered) { + return null + } + // Codex refuses an interrupt before it opens the turn. + await session.turnOpenWaits.wait(answered, CODEX_STOP_TURN_OPEN_WAIT_MS) + return session.activeTurnIds?.has(answered) ? answered : null +} + export async function cancelCodexStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction - cancellation: CodexStructuredTurnCancellation -}): Promise<{ cancelled: boolean }> { - const { request, sessions, compactions, cancellation } = input + requestTimeoutMs?: number +}): Promise { + const { request, sessions, requestTimeoutMs } = input const session = requireLiveCodexSession(sessions, request.sessionId) - const turnId = compactions.providerTurnId(request.sessionId, request.turnId) + const prompt = request.prompt + const requestedTurnId = request.turnId + if (requestedTurnId === undefined) { + return prompt ? { cancelled: false } : cancelCodexConversation(input, session) + } + const turnId = providerTurnId(session, requestedTurnId) if (!turnId) { return { cancelled: false } } - const prompt = request.prompt if (!prompt) { - return cancellation.cancel(session, session.threadId, turnId) + return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) } if (session.fence !== request.fence) { return { cancelled: false } } - const acquisitionGeneration = session.acquisitionGeneration const claim = session.prompts.claimBound(prompt.itemId) const promptTurnId = claim?.prompt.turnId if (!claim || !promptTurnId) { @@ -44,25 +97,18 @@ export async function cancelCodexStructuredTurn(input: { } return { cancelled: false } } - const isCurrent = (): boolean => - sessions.get(request.sessionId) === session && - !session.ended && - session.fence === request.fence && - session.acquisitionGeneration === acquisitionGeneration && - compactions.providerTurnId(request.sessionId, request.turnId) === turnId && - session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) let interruptConfirmed = false try { - const result = await cancellation.cancel( + const result = await interruptCodexTurn({ session, - claim.prompt.threadId, - promptTurnId, - isCurrent, - () => { + threadId: claim.prompt.threadId, + turnId: promptTurnId, + requestTimeoutMs, + onConfirmed: () => { interruptConfirmed = true return session.translator?.cancelPrompt(prompt.itemId) ?? { accepted: true } } - ) + }) if (!result.cancelled) { session.prompts.releaseClaim(claim) } diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 06563cad748..8b59209a768 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -4,14 +4,13 @@ import type { CodexJournalTranslationAdmission } from './codex-structured-journa import * as codexRewind from './codex-structured-rewind' import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' import { readCodexThreadId } from './codex-structured-thread-facts' -import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' type EmitCodexEvent = ( session: CodexSession, event: CodexStructuredSessionEvent ) => CodexJournalTranslationAdmission -/** One live notification's journal entry: rewind bookkeeping, cancellation deferral, delivery. */ +/** One live notification's journal entry: rewind bookkeeping, delivery. */ export function translateCodexNotification(input: { sessionId: string session: CodexSession @@ -19,14 +18,11 @@ export function translateCodexNotification(input: { params: unknown observedAt?: number dispatchSequenceAtReceipt?: number - turnCancellation: Pick emit: EmitCodexEvent }): CodexJournalTranslationAdmission { const { sessionId, session, method, params, observedAt, dispatchSequenceAtReceipt } = input codexRewind.observeCodexRewindActivity(session, method, params) - if (input.turnCancellation.handleNotification(sessionId, session, method, params, observedAt)) { - return { accepted: true } - } + session.turnOpenWaits.observe(session.threadId, method, params) return deliverCodexNotification( sessionId, session, diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index ff9a210e17a..069fe4eb048 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -5,7 +5,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession, @@ -13,7 +16,8 @@ import { } from '../../shared/structured-agent-session-reducer' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' -import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -31,6 +35,7 @@ import { projectStructuredQuestionMessages, structuredQuestionTranscript } from '../../renderer/src/components/native-chat/structured-agent-question-projection' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -86,11 +91,11 @@ beforeEach(async () => { answerPrompt: vi.fn(async ({ commit }) => commit()), setOption: vi.fn(async () => undefined) } - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', // Every write lands on its own millisecond, as it does live. @@ -125,7 +130,7 @@ function codexPrompts(): CodexJournalPrompts { throw new Error('session was never acquired') } return new CodexJournalPrompts( - { sink, linkageFor: () => ({}) }, + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, () => null, () => 'turn-1' ) diff --git a/src/main/codex/codex-structured-rewind.test.ts b/src/main/codex/codex-structured-rewind.test.ts index 64ebff43e38..a5f34cc98fd 100644 --- a/src/main/codex/codex-structured-rewind.test.ts +++ b/src/main/codex/codex-structured-rewind.test.ts @@ -204,11 +204,10 @@ describe('Codex rewind', () => { } return original(method) }) - const onReverted = vi.fn() - expect( - await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted }) - ).toEqual({ ok: false, reason: 'history-limit' }) - expect(onReverted).not.toHaveBeenCalled() + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'history-limit' + }) expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) }) it('refuses a missing target before mutation', async () => { diff --git a/src/main/codex/codex-structured-rewind.ts b/src/main/codex/codex-structured-rewind.ts index e5913b37be5..b222694422d 100644 --- a/src/main/codex/codex-structured-rewind.ts +++ b/src/main/codex/codex-structured-rewind.ts @@ -280,7 +280,6 @@ export async function rewindCodexSession( if (record(reply.thread).id !== session.threadId) { throw new Error('agent_session_rewind:foreign-thread') } - await input.onReverted?.() const items = await verifyCodexRevertedHistory(session, reply, input.beforeTurnId, timeoutMs) if ( items.length !== expectedItems.size || diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 1cacd874b1e..3f1719c2be4 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -33,6 +33,7 @@ import { reportedCodexThreadOptions } from './codex-structured-fast-mode' import { + assertCodexConnectionOpen, codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, @@ -40,7 +41,7 @@ import { type CodexSession, type CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' -import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { deliverCodexServerRequest } from './codex-structured-provider-events' @@ -51,7 +52,6 @@ export async function acquireCodexStructuredSession(input: { deps: CodexStructuredSessionAdapterDeps sessions: Map acquisitions: CodexAcquisitionRegistry - turnCancellation: CodexStructuredTurnCancellation notificationRetries: CodexStructuredNotificationRetry deliver: ( acquisition: CodexAcquisitionAttempt['window'], @@ -64,21 +64,9 @@ export async function acquireCodexStructuredSession(input: { request: Parameters[2] ) => void handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void - forceCloseUnexpected: ( - sessionId: string, - fence: number, - acquisitionGeneration: string, - reason: Error - ) => Promise + forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] }): Promise { - const { - input: acquireInput, - deps, - sessions, - acquisitions, - turnCancellation, - notificationRetries - } = input + const { input: acquireInput, deps, sessions, acquisitions, notificationRetries } = input const sessionId = acquireInput.identity.sessionId const { previousAttempt, attempt } = acquisitions.start(sessionId) const acquisition = attempt.window @@ -223,9 +211,7 @@ export async function acquireCodexStructuredSession(input: { }), acquisitionGeneration: mintCodexAcquisitionGeneration(deps) } - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.assertCurrent(sessionId, attempt) const options = restoredCodexSessionOptions(acquireInput.options) const catalogAccess = codexAcquireCatalogAccess(deps, launch) @@ -237,9 +223,7 @@ export async function acquireCodexStructuredSession(input: { timeoutMs: deps.requestTimeoutMs }) acquisitions.assertCurrent(sessionId, attempt) - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.deleteIfCurrent(sessionId, attempt) // Where this session's child work goes: the host's records, after each frame is journaled. const sink = codexChildWorkSink(sessionId, deps) @@ -277,7 +261,6 @@ export async function acquireCodexStructuredSession(input: { ?.supportsFastMode }) } - turnCancellation.register(session) sessions.set(sessionId, session) for (const event of acquisition.drain()) { event() diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index 3f2d32ee2eb..45c97f00066 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -11,7 +11,6 @@ import type { import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, - type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -89,13 +88,23 @@ export function fakeCodex(routes: Record = {}): { return { connections, openConnection, routes } } +/** A `turn/start` route for a Codex that opened the turn before its answer was read. */ +export function answerWithOpenedTurn( + codex: Pick, 'connections'>, + turnId: string +): Route { + return () => { + codex.connections + .at(-1) + ?.handlers.onNotification?.('turn/started', { threadId: THREAD_ID, turn: { id: turnId } }) + return { turn: { id: turnId } } + } +} + export function adapterFor( codex: ReturnType, launch: Partial = {}, - events: CodexStructuredSessionEvent[] = [], - processControl: Partial< - Pick - > = {} + events: CodexStructuredSessionEvent[] = [] ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 return new CodexStructuredSessionAdapter({ @@ -110,11 +119,8 @@ export function adapterFor( onEvent: (event) => events.push(event), openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, - captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), - terminateTurnProcesses: async () => true, now: () => 1_700_000_000_500, - mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`, - ...processControl + mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}` }) } diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index bcfd2f733f0..e3211f320e8 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -14,7 +14,6 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, - type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -99,10 +98,7 @@ function fakeCodex(routes: Record = {}): { function adapterFor( codex: ReturnType, launch: Partial = {}, - events: CodexStructuredSessionEvent[] = [], - processControl: Partial< - Pick - > = {} + events: CodexStructuredSessionEvent[] = [] ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 return new CodexStructuredSessionAdapter({ @@ -117,11 +113,8 @@ function adapterFor( onEvent: (event) => events.push(event), openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, - captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), - terminateTurnProcesses: async () => true, now: () => 1_700_000_000_500, - mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`, - ...processControl + mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}` }) } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2fa337b6001..70b2ecd7147 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -16,6 +16,7 @@ import { USER_MESSAGE, acquired, adapterFor, + answerWithOpenedTurn, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' @@ -371,7 +372,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { describe('CodexStructuredSessionAdapter.dispatch', () => { it('admits a send as soon as Codex owns it', async () => { - const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) const outcome = await adapter.dispatch({ @@ -512,9 +514,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { } ], nextCursor: null - }), - 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 80bbc12fcb4..13a687a72cd 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -4,7 +4,6 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, @@ -15,6 +14,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { @@ -34,8 +34,10 @@ import { deliverCodexUnhandledFrame, translateCodexNotification } from './codex-structured-provider-events' -import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' -import { settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' +import { + codexDispatchRejection, + settleCodexSendsInEndedTurn +} from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' import { changeCodexThreadGoal } from './codex-structured-thread-goal' @@ -51,10 +53,8 @@ export type { } from './codex-structured-session-state' export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new CodexAcquisitionRegistry() - private readonly turnCancellation: CodexStructuredTurnCancellation private readonly notificationRetries: ReturnType private readonly teardown: CodexStructuredSessionTeardown @@ -69,7 +69,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap params, observedAt, dispatchSequenceAtReceipt, - turnCancellation: this.turnCancellation, emit: (current, event) => this.emit(current, event) }) }) @@ -79,25 +78,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null) }) - this.turnCancellation = new CodexStructuredTurnCancellation({ - captureTurnProcesses: deps.captureTurnProcesses, - terminateTurnProcesses: deps.terminateTurnProcesses, - requestTimeoutMs: deps.requestTimeoutMs, - emit: (session, event) => { - const admission = this.emit(session, event) - if (!admission.accepted && event.type === 'notification') { - const { sessionId, method, params, observedAt, dispatchSequenceAtReceipt } = event - this.notificationRetries.handle( - sessionId, - method, - params, - observedAt, - dispatchSequenceAtReceipt - ) - } - return admission - } - }) } supportsLocation = (location: Parameters[0]): boolean => @@ -109,7 +89,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap deps: this.deps, sessions: this.sessions, acquisitions: this.acquisitions, - turnCancellation: this.turnCancellation, notificationRetries: this.notificationRetries, deliver: (acquisition, sessionId, event, retainedBytes) => this.deliver(acquisition, sessionId, event, retainedBytes), @@ -152,7 +131,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return admission } if (event.type === 'notification') { - this.compactions.codex(event.sessionId, event.method, event.params) // Only an admitted turn end settles; a refused one settles on the retry that lands. settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) @@ -163,9 +141,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // After the journal and the parent's republished row, never ahead of either. session.backgroundTasks.publishChildWork() } - if (event.type === 'ended') { - this.compactions.ended(event.sessionId) - } this.deps.onEvent?.(event) return admission } @@ -227,7 +202,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap const session = this.session(input.sessionId) session.dispatchPending = true try { - await this.turnCancellation.captureBaseline(session) await input.beforeDispatch?.() return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) } finally { @@ -239,8 +213,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap cancelCodexStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, - cancellation: this.turnCancellation + requestTimeoutMs: this.deps.requestTimeoutMs }) rewindSupport: NonNullable = (sessionId) => @@ -254,33 +227,30 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap recoverRewind: NonNullable = (input) => codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs) - compact: NonNullable = (input) => { + /** The ack is Codex's receipt; the translator ends the command's turn from the turn it opens. */ + compact: NonNullable = async (input) => { const session = this.session(input.sessionId) - return this.compactions.run( - input.sessionId, - session.threadId, - async () => { - await this.turnCancellation.captureBaseline(session) - return session.connection - .request( - 'thread/compact/start', - { threadId: session.threadId }, - { timeoutMs: this.deps.requestTimeoutMs } + session.translator?.beginCommand(input.command) + try { + await session.connection.request( + 'thread/compact/start', + { threadId: session.threadId }, + { timeoutMs: this.deps.requestTimeoutMs } + ) + return { state: 'accepted', providerIdentity: null } + } catch (error) { + session.translator?.forgetCommand(input.command.turnId) + if (isCodexAppServerRequestError(error)) { + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) ) - .then( - () => undefined, - (error) => { - if (isCodexAppServerRequestError(error)) { - const detail = error.providerDiagnostic - return { outcome: 'failed' as const, ...(detail ? { detail } : {}) } - } - throw error - } - ) - }, - input.onLateResult, - input.turnId - ) + } + } + throw error + } } changeThreadGoal: NonNullable = (input) => diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 1ac807a5ccd..cd30bfa2e6d 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -1,4 +1,35 @@ import { describe, expect, it, vi } from 'vitest' + +// Every way Orca reads or kills processes, never settling: a Stop that reaches for one hangs. +const processWork = vi.hoisted(() => { + const never = (): Promise => new Promise(() => {}) + return { + captureDescendantSnapshot: vi.fn(async () => ({ + rootPgid: null, + descendants: [], + capturedAtMs: 0 + })), + terminateDescendantSnapshotAndWait: vi.fn(never), + queryWindowsProcessDescendants: vi.fn(never), + terminateWindowsProcessTree: vi.fn(never) + } +}) +vi.mock('../pty-descendant-termination', async (importOriginal) => ({ + ...(await importOriginal>()), + captureDescendantSnapshot: processWork.captureDescendantSnapshot +})) +vi.mock('../pty-descendant-exit-verification', async (importOriginal) => ({ + ...(await importOriginal>()), + terminateDescendantSnapshotAndWait: processWork.terminateDescendantSnapshotAndWait +})) +vi.mock('../providers/windows-foreground-process-rows', async (importOriginal) => ({ + ...(await importOriginal>()), + queryWindowsProcessDescendants: processWork.queryWindowsProcessDescendants +})) +vi.mock('../windows-process-tree-kill', async (importOriginal) => ({ + ...(await importOriginal>()), + terminateWindowsProcessTree: processWork.terminateWindowsProcessTree +})) import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -79,12 +110,7 @@ function fakeCodex(): { async function acquired( codex: ReturnType, events: CodexStructuredSessionEvent[] = [], - processControl: Partial< - Pick< - CodexStructuredSessionAdapterDeps, - 'captureTurnProcesses' | 'terminateTurnProcesses' | 'now' - > - > = {} + overrides: Partial> = {} ): Promise { const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -97,9 +123,7 @@ async function acquired( onEvent: (event) => events.push(event), openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, - captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), - terminateTurnProcesses: async () => true, - ...processControl + ...overrides }) await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' }) return adapter @@ -142,14 +166,14 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) await expect( (await acquired(absent)).cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) }) it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => { @@ -167,75 +191,18 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { ).rejects.toThrow('exceeded 30000ms') }) - it('publishes terminal state only after streaming interruption is physically settled', async () => { - const events: CodexStructuredSessionEvent[] = [] - let finishTermination!: (terminated: boolean) => void - const termination = new Promise((resolve) => { - finishTermination = resolve - }) - const codex = fakeCodex() - codex.routes['turn/interrupt'] = () => { - completeTurn(codex) - return {} - } - const adapter = await acquired(codex, events, { - terminateTurnProcesses: async () => termination - }) - codex.connections[0].handlers.onNotification?.('item/agentMessage/delta', { - threadId: THREAD_ID, - turnId: 'turn-1', - itemId: 'item-1', - delta: 'still streaming' - }) - - const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt')) - expect(events).toContainEqual(expect.objectContaining({ method: 'item/agentMessage/delta' })) - expect(events).not.toContainEqual(expect.objectContaining({ method: 'turn/completed' })) - - finishTermination(true) - await expect(pending).resolves.toEqual({ cancelled: true }) - expect(events.at(-1)).toMatchObject({ method: 'turn/completed' }) - }) - - it('starts physical termination without waiting for the interrupt receipt', async () => { - let finishInterrupt!: () => void - const interruptReceipt = new Promise((resolve) => { - finishInterrupt = resolve - }) - const terminateTurnProcesses = vi.fn(async () => true) - const codex = fakeCodex() - codex.routes['turn/interrupt'] = () => interruptReceipt - const adapter = await acquired(codex, [], { terminateTurnProcesses }) - - const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - await vi.waitFor(() => expect(terminateTurnProcesses).toHaveBeenCalledOnce()) - finishInterrupt() - - await expect(pending).resolves.toEqual({ cancelled: true }) - }) - - it('keeps the turn live when process termination cannot be verified', async () => { - const events: CodexStructuredSessionEvent[] = [] - const codex = fakeCodex() - codex.routes['turn/interrupt'] = () => { - completeTurn(codex) - return {} - } - const adapter = await acquired(codex, events, { - terminateTurnProcesses: async () => false - }) - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) - }) - it('accepts an immediate resend after verified interruption', async () => { let nextTurn = 0 const codex = fakeCodex() - codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } }) + codex.routes['turn/start'] = () => { + // Codex opens each turn it answers; a send's dispatch waits for that. + const turnId = `turn-${++nextTurn}` + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: turnId } + }) + return { turn: { id: turnId } } + } codex.routes['turn/interrupt'] = () => { completeTurn(codex) return {} @@ -262,42 +229,14 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { }) }) - it('keeps the receipt time of a completion deferred behind physical termination', async () => { - const events: CodexStructuredSessionEvent[] = [] - let clock = 5_000 - let finishTermination!: (terminated: boolean) => void - const termination = new Promise((resolve) => { - finishTermination = resolve - }) - const codex = fakeCodex() - codex.routes['turn/interrupt'] = () => { - completeTurn(codex) - return {} - } - const adapter = await acquired(codex, events, { - terminateTurnProcesses: async () => termination, - now: () => clock - }) - - const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt')) - clock = 9_000 - finishTermination(true) - await expect(pending).resolves.toEqual({ cancelled: true }) - - expect(events.at(-1)).toMatchObject({ method: 'turn/completed', observedAt: 5_000 }) - }) - - it('does not strand a deferred completion when the interrupt receipt fails', async () => { + it("publishes the turn's end when the interrupt receipt fails", async () => { const events: CodexStructuredSessionEvent[] = [] const codex = fakeCodex() codex.routes['turn/interrupt'] = () => { completeTurn(codex) throw new Error('interrupt receipt lost') } - const adapter = await acquired(codex, events, { - terminateTurnProcesses: async () => true - }) + const adapter = await acquired(codex, events) await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) @@ -305,3 +244,64 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) }) }) + +// Codex keeps a turn's background terminals alive across an interrupt and kills its one-shot +// commands itself, so a Stop is the interrupt alone. +describe('a Codex Stop is the interrupt alone', () => { + async function stopAfterASend() { + const events: CodexStructuredSessionEvent[] = [] + const answer = Promise.withResolvers() + const codex = fakeCodex() + codex.routes['turn/start'] = () => { + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + return { turn: { id: 'turn-1' } } + } + codex.routes['turn/interrupt'] = () => answer.promise + const adapter = await acquired(codex, events) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + const stopped = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt')) + // Codex answers the interrupt, then sends the turn's end; one read can carry both. + const answerThenEnd = (): void => { + answer.resolve({}) + completeTurn(codex) + } + return { events, stopped, answerThenEnd } + } + + it('publishes the interrupted end the moment Codex sends it', async () => { + const { events, stopped, answerThenEnd } = await stopAfterASend() + + answerThenEnd() + + expect(events).toContainEqual( + expect.objectContaining({ + method: 'turn/completed', + params: expect.objectContaining({ turn: { id: 'turn-1', status: 'interrupted' } }) + }) + ) + await expect(stopped).resolves.toEqual({ cancelled: true }) + }) + + it('reads and kills no processes, from the send through the Stop', async () => { + const { stopped, answerThenEnd } = await stopAfterASend() + + answerThenEnd() + + for (const work of Object.values(processWork)) { + expect(work).not.toHaveBeenCalled() + } + await expect(stopped).resolves.toEqual({ cancelled: true }) + for (const work of Object.values(processWork)) { + expect(work).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index dffa7157dd6..9d80a0fa982 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -76,7 +77,7 @@ function claudeAdapterStub(): StructuredAgentSessionAdapter { } describe('Codex structured session close lifecycle', () => { - it('forwards a one-shot exit when lifecycle admission is rejected', () => { + function backpressuredSession(requestedClose: boolean) { const connection: CodexAppServerConnection = { pid: 4321, closed: true, @@ -87,7 +88,6 @@ describe('Codex structured session close lifecycle', () => { close: async () => true } const prompts = new CodexPromptRegistry() - const clearPrompts = vi.spyOn(prompts, 'clear') const translator = { handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), dispose: vi.fn() @@ -96,7 +96,7 @@ describe('Codex structured session close lifecycle', () => { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, + requestedClose, fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, @@ -106,9 +106,15 @@ describe('Codex structured session close lifecycle', () => { reportedOptions: {}, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator } - const sessions = new Map([['session-1', session]]) + return { connection, prompts, translator, session, sessions: new Map([['session-1', session]]) } + } + + it('forwards a one-shot exit when lifecycle admission is rejected', () => { + const { connection, prompts, translator, session, sessions } = backpressuredSession(false) + const clearPrompts = vi.spyOn(prompts, 'clear') const onEvent = vi.fn() expect( @@ -129,6 +135,29 @@ describe('Codex structured session close lifecycle', () => { expect(translator.handle).toHaveBeenCalledOnce() }) + it("ends a Stop's wait for its turn to open when a requested close cannot publish its end yet", async () => { + const { connection, prompts, session, sessions } = backpressuredSession(true) + let released = false + void session.turnOpenWaits.wait('turn-1', 60_000).then(() => { + released = true + }) + + expect( + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection, + error: new Error('codex session closed'), + closedByOrca: true, + prompts + }) + ).toBe(false) + await Promise.resolve() + // Left for the retry, but the child is gone: nothing waits on a turn it would open. + expect(session.ended).toBe(false) + expect(released).toBe(true) + }) + it('mints a distinct child generation even when acquisitions share one fence', async () => { const { adapter } = adapterFixture() const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 76cca467947..fe6c0d67673 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -27,6 +27,8 @@ export function handleCodexSessionExit(input: { return false } session.exitObservedAt ??= Date.now() + // Before the admission check: the child is gone whether or not its end was admitted. + session.turnOpenWaits.releaseAll() const event: StructuredAgentSessionEndedEvent = { type: 'ended', sessionId: input.sessionId, diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index a9fd420eb0a..aac9e2946cf 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import { @@ -62,6 +63,7 @@ function storeSession( reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator: null, catalogAccess: { store, fingerprint: FINGERPRINT, accountHomePath: '/homes/a' } } diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 47ed8b50e3a..d99ecc0305e 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' @@ -36,6 +37,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator: null } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index a1e8c001750..a47cb5a78fe 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -10,11 +10,14 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { + createCodexTurnOpenWaits, + type CodexTurnOpenWaits +} from './codex-structured-turn-open-wait' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { @@ -93,11 +96,6 @@ export type CodexStructuredSessionAdapterDeps = { mintAcquisitionGeneration?: () => string now?: () => number requestTimeoutMs?: number - captureTurnProcesses?: (rootPid: number) => Promise - terminateTurnProcesses?: ( - rootPid: number, - baseline: CodexTurnProcessSnapshot | null - ) => Promise /** Host model catalog; sessions write their listings through and read back. */ modelCatalog?: AgentModelCatalogStore } @@ -113,7 +111,11 @@ export type CodexSession = { threadId: string historyPath: string | null historyMode?: 'legacy' | 'paginated' + /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what + * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ activeTurnIds?: Set + /** Stops waiting for the turn Codex answered a send into to open. */ + turnOpenWaits: CodexTurnOpenWaits dispatchPending?: boolean prompts: CodexAcquisitionWindow['prompts'] options: Map @@ -144,8 +146,27 @@ export function mintCodexAcquisitionGeneration(deps: CodexStructuredSessionAdapt export function codexSessionLifecycle( fence: number, acquisitionGeneration: string -): Pick { - return { ended: false, requestedClose: false, fence, acquisitionGeneration } +): Pick< + CodexSession, + 'ended' | 'requestedClose' | 'fence' | 'acquisitionGeneration' | 'turnOpenWaits' +> { + return { + ended: false, + requestedClose: false, + fence, + acquisitionGeneration, + turnOpenWaits: createCodexTurnOpenWaits() + } +} + +/** A child that exited while being acquired never becomes the session's. */ +export function assertCodexConnectionOpen( + connection: Pick, + sessionId: string +): void { + if (connection.closed) { + throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) + } } export function requireLiveCodexSession( diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index aaf65f93ec6..f2a42e6d3db 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -65,14 +65,6 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { /** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a * bare string. `idle` and `systemError` are the two arms that mean the thread * is not running; `active` and `notLoaded` are not. */ -/** - * Codex hardcodes `willRetry: false` on an `error` that affects turn status, and `true` on a - * stream error it is about to retry, which ends nothing. - */ -export function readCodexErrorWillRetry(payload: unknown): boolean { - return record(payload)?.willRetry === true -} - export function codexThreadStoppedRunning(payload: unknown): boolean { const type = record(record(payload)?.status)?.type return type === 'idle' || type === 'systemError' diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 4418da81057..99af63f9d6f 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -1,194 +1,41 @@ -import { - isCodexAppServerRequestError, - type CodexAppServerConnection -} from './codex-app-server-connection' +import { isCodexAppServerRequestError } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import type { - CodexSession, - CodexStructuredSessionAdapterDeps, - CodexStructuredSessionEvent -} from './codex-structured-session-state' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { CodexSession } from './codex-structured-session-state' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' -import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' -import { - captureCodexTurnProcesses, - terminateCodexTurnProcesses, - type CodexTurnProcessSnapshot -} from './codex-structured-turn-processes' -type TurnProcessState = { - baseline: Promise - blockedCompletions: Set - deferredCompletions: Map -} - -function turnKey(threadId: string, turnId: string): string { - return JSON.stringify([threadId, turnId]) -} - -type TurnCancellationDeps = Pick< - CodexStructuredSessionAdapterDeps, - 'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses' -> & { - emit: ( - session: CodexSession, - event: CodexStructuredSessionEvent - ) => CodexJournalTranslationAdmission -} - -const ADMITTED: CodexJournalTranslationAdmission = { accepted: true } - -export class CodexStructuredTurnCancellation { - private readonly states = new WeakMap() - - constructor(private readonly deps: TurnCancellationDeps) {} - - register(session: CodexSession): void { - this.states.set(session, { - baseline: Promise.resolve(null), - blockedCompletions: new Set(), - deferredCompletions: new Map() - }) - } - - captureBaseline(session: CodexSession): Promise { - this.refreshBaseline(session) - return this.state(session).baseline - } - - handleNotification( - sessionId: string, - session: CodexSession, - method: string, - params: unknown, - observedAt?: number - ): boolean { - const threadId = readCodexThreadId(params) ?? session.threadId - if (method !== 'turn/completed') { - return false - } - const turnId = readCodexTurnId(params) - const state = this.state(session) - const key = turnId ? turnKey(threadId, turnId) : null - if (!key || !state.blockedCompletions.has(key)) { - return false - } - const event = { - type: 'notification' as const, - sessionId, - threadId, - method, - params, - ...(observedAt !== undefined ? { observedAt } : {}) +/** + * Codex answers a turn's interrupt only as that turn ends, so the answer is what confirms the + * Stop. The interrupt is the whole Stop: Codex kills the turn's one-shot commands itself and keeps + * its background terminals running until the thread ends. + */ +export async function interruptCodexTurn(input: { + session: CodexSession + threadId: string + turnId: string + requestTimeoutMs?: number + onConfirmed?: () => CodexJournalTranslationAdmission +}): Promise { + const { session, threadId, turnId } = input + try { + await session.connection.request( + 'turn/interrupt', + { threadId, turnId }, + { timeoutMs: input.requestTimeoutMs } + ) + } catch (error) { + if (!isCodexAppServerRequestError(error) && !isCodexAppServerUnsupportedError(error)) { + throw error } - state.deferredCompletions.set(key, event) - return true + const detail = providerDiagnosticOf(error) + return { cancelled: false, refusal: detail ? { detail } : {} } } - - async cancel( - session: CodexSession, - threadId: string, - turnId: string, - isCurrent: () => boolean = () => true, - onConfirmed?: () => CodexJournalTranslationAdmission - ): Promise<{ cancelled: boolean }> { - const state = this.state(session) - const key = turnKey(threadId, turnId) - state.blockedCompletions.add(key) - const targetsPrimaryTurn = threadId === session.threadId - const baseline = targetsPrimaryTurn ? await state.baseline : null - if (!isCurrent()) { - this.releaseCompletion(session, key) - return { cancelled: false } - } - let requestError: unknown - const interruptReceipt = session.connection - .request('turn/interrupt', { threadId, turnId }, { timeoutMs: this.deps.requestTimeoutMs }) - .then( - () => true, - (error: unknown) => { - requestError = error - return false - } - ) - const [acknowledged, terminated] = await Promise.all([ - interruptReceipt, - targetsPrimaryTurn ? this.terminate(session.connection, baseline) : Promise.resolve(true) - ]) - if (terminated && acknowledged) { - const completion = state.deferredCompletions.get(key) - let confirmationError: unknown - let promptAdmission = ADMITTED - try { - promptAdmission = onConfirmed?.() ?? ADMITTED - } catch (error) { - confirmationError = error - } - const completionAdmission = this.releaseCompletion(session, key, completion) - if (confirmationError) { - throw confirmationError - } - if (!promptAdmission.accepted) { - throw new Error( - `Codex prompt cancellation lifecycle was not admitted (${promptAdmission.reason})` - ) - } - if (onConfirmed && completion && !completionAdmission.accepted) { - throw new Error( - `Codex deferred turn completion lifecycle was not admitted (${completionAdmission.reason})` - ) - } - return { cancelled: true } - } - if ( - requestError && - !isCodexAppServerRequestError(requestError) && - !isCodexAppServerUnsupportedError(requestError) - ) { - this.releaseCompletion(session, key) - throw requestError - } - // A failed cancellation must not permanently divert the provider's later - // completion for this turn. Let the normal completion path settle it. - this.releaseCompletion(session, key) - return { cancelled: false } - } - - private capture(pid: number | undefined): Promise { - return pid - ? (this.deps.captureTurnProcesses ?? captureCodexTurnProcesses)(pid) - : Promise.resolve(null) - } - - private terminate( - connection: Pick, - baseline: CodexTurnProcessSnapshot | null - ): Promise { - return connection.pid - ? (this.deps.terminateTurnProcesses ?? terminateCodexTurnProcesses)(connection.pid, baseline) - : Promise.resolve(false) - } - - private refreshBaseline(session: CodexSession): void { - this.state(session).baseline = this.capture(session.connection.pid) - } - - private releaseCompletion( - session: CodexSession, - key: string, - completion = this.state(session).deferredCompletions.get(key) - ): CodexJournalTranslationAdmission { - const state = this.state(session) - state.blockedCompletions.delete(key) - state.deferredCompletions.delete(key) - return completion ? this.deps.emit(session, completion) : ADMITTED - } - - private state(session: CodexSession): TurnProcessState { - const state = this.states.get(session) - if (!state) { - throw new Error('codex turn process state is unavailable') - } - return state + const promptAdmission = input.onConfirmed?.() + if (promptAdmission && !promptAdmission.accepted) { + throw new Error( + `Codex prompt cancellation lifecycle was not admitted (${promptAdmission.reason})` + ) } + return { cancelled: true } } diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 60f5a3c5f38..38cc648f79d 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -36,17 +36,23 @@ async function turnEndRig() { body: CODEX_TEST_USER_MESSAGE, fence: 7 }) + // Codex answers a cold send before it opens the turn. + const sendAndOpen = async (clientMessageId: string) => { + const sending = send(clientMessageId) + await vi.waitFor(() => expect(turns.turnId).not.toBeNull()) + turns.start() + return sending + } const settledIds = () => settlements.map(({ clientMessageId }) => clientMessageId) const categoryOf = (settlement: LateSettlement | undefined) => settlement && 'state' in settlement ? classifyDispatchRejection(settlement).category : null - return { codex, turns, adapter, send, settlements, settledIds, categoryOf, bodies } + return { codex, turns, adapter, send, sendAndOpen, settlements, settledIds, categoryOf, bodies } } describe('a Codex send its turn ended without echoing', () => { it('is withdrawn when the turn is interrupted, once', async () => { const rig = await turnEndRig() - await expect(rig.send('client-1')).resolves.toEqual({ state: 'admitted' }) - rig.turns.start() + await expect(rig.sendAndOpen('client-1')).resolves.toEqual({ state: 'admitted' }) rig.turns.end('interrupted') @@ -62,8 +68,7 @@ describe('a Codex send its turn ended without echoing', () => { it('is rejected in Codex words when its failed turn ends without echoing it', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.codex.connections[0]!.handlers.onNotification?.('error', { threadId: CODEX_TEST_THREAD_ID, @@ -87,8 +92,7 @@ describe('a Codex send its turn ended without echoing', () => { it('is accepted when Codex records it after the error that fails its turn', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.echo('client-1') await rig.send('client-2') @@ -113,8 +117,7 @@ describe('a Codex send its turn ended without echoing', () => { it('stays pending, still armed, when its turn completes without echoing it', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.end('completed') expect(rig.settlements).toEqual([]) @@ -131,8 +134,7 @@ describe('a Codex send its turn ended without echoing', () => { it('accepts a send its turn echoed, with its key, exactly once', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.echo('client-1') rig.turns.end('interrupted') @@ -153,8 +155,7 @@ describe('a Codex send its turn ended without echoing', () => { it('ignores an echo that arrives after the withdrawal', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.end('interrupted') rig.turns.echo('client-1') @@ -165,8 +166,7 @@ describe('a Codex send its turn ended without echoing', () => { it('settles each of two sends steered into one interrupted turn once', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') await rig.send('client-2') expect(rig.turns.turnId).toBe('turn-1') @@ -275,8 +275,7 @@ describe('a Codex send its turn ended without echoing', () => { it('ignores a turn end on a child thread', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.codex.connections[0]!.handlers.onNotification?.('turn/completed', { threadId: 'thread-child', diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 5187aabf45a..65700644933 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -1,6 +1,8 @@ -// A send Codex answered into a turn that then ended without echoing it. Codex clears -// a turn's pending input when it is interrupted, so that send never reached the model -// and is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending +// A send Codex answered into a turn that then ended without echoing it. On an interrupt, a +// steered send waits in Codex's pending input, which the interrupt clears; a send that opened the +// turn is saved as the turn cancels, but Codex hard-aborts that save after 100 ms, so it is lost +// or reaches only the model's context, and the thread history shows no user message either way. +// So it is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending // input before `turn/completed`, a failed turn after its `error` frame, so only that // frame settles: a failed turn that never echoed the send refused it, in Codex's words, // and a completed one leaves it pending for the journal's recovery on exit. diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts new file mode 100644 index 00000000000..125be2bba0b --- /dev/null +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -0,0 +1,147 @@ +// A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that +// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps +// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt +// until then. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CODEX_TEST_THREAD_ID, + codexTurnLifecycleRig, + settledWithin +} from './codex-structured-dispatch-test-support' +import { CODEX_STOP_TURN_OPEN_WAIT_MS } from './codex-structured-prompt-ownership' + +type Rig = Awaited> + +const ADMITTED = { state: 'admitted' } +const REFUSED = { cancelled: false } + +const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + +/** A cold send Codex answered into `turn-1` and has not opened. */ +async function answeredColdSend(rig: Rig): Promise { + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + // The send is not held for the turn to open: its handover ends at the answer. + expect(await settledWithin(sending)).toEqual(ADMITTED) +} + +/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +async function waitingStop(rig: Rig) { + await answeredColdSend(rig) + const stopping = stop(rig) + expect(await settledWithin(stopping)).toBe('held') + expect(rig.interrupts()).toEqual([]) + // Wrapped: an async function returning the promise itself would wait for it. + return { stopping } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a Codex send answered before its turn opens', () => { + it('is admitted at the answer when no Stop is pending', async () => { + const rig = await codexTurnLifecycleRig() + + await answeredColdSend(rig) + + expect(rig.turns.turnId).toBe('turn-1') + }) +}) + +describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { + it('waits for the turn to open, then stops it', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.turns.start() + + expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + expect(rig.turns.turnId).toBeNull() + }) + + it('does not wait when Codex opened the turn before its answer was read', async () => { + const rig = await codexTurnLifecycleRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + release() + await sending + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + }) + + it('does not wait for a send Codex steered into the running turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.start() + expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + }) + + it('stops nothing when the turn ends without opening', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.turns.end('interrupted') + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) + + it.each(['idle', 'systemError'])( + 'stops nothing when Codex reports the thread %s', + async (type) => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + } + ) + + it('keeps waiting when a child thread stops running', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.notify('thread/status/changed', { threadId: 'thread-child', status: { type: 'idle' } }) + + expect(await settledWithin(stopping)).toBe('held') + rig.turns.start() + expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + }) + + it('stops nothing when the child exits', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing once its bound runs out', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + + await vi.advanceTimersByTimeAsync(CODEX_STOP_TURN_OPEN_WAIT_MS - 1) + expect(outcome).toBe('held') + await vi.advanceTimersByTimeAsync(1) + + expect(outcome).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts new file mode 100644 index 00000000000..1e303a58fba --- /dev/null +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -0,0 +1,56 @@ +// A Stop's wait for the turn Codex answered a send into to open, or provably not to: it ended, +// the thread stopped running, or the child is gone. Held in memory only. + +import { + codexThreadStoppedRunning, + readCodexThreadId, + readCodexTurnId +} from './codex-structured-thread-facts' + +export type CodexTurnOpenWaits = { + /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ + wait: (turnId: string, withinMs: number) => Promise + /** Ends the waits a notification on the session's own thread answers. */ + observe: (threadId: string, method: string, params: unknown) => void + /** Ends every wait: the child that would open their turns is gone. */ + releaseAll: () => void +} + +export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { + const waits = new Map<() => void, string>() + const release = (turnId?: string): void => { + for (const [endWait, waitedTurnId] of waits) { + if (turnId === undefined || waitedTurnId === turnId) { + endWait() + } + } + } + return { + wait: (turnId, withinMs) => + new Promise((resolve) => { + const endWait = (): void => { + clearTimeout(bound) + waits.delete(endWait) + resolve() + } + const bound = setTimeout(endWait, withinMs) + // A Stop's wait must never be what keeps the process alive at quit. + bound.unref?.() + waits.set(endWait, turnId) + }), + observe: (threadId, method, params) => { + if ((readCodexThreadId(params) ?? threadId) !== threadId) { + return + } + if (method === 'thread/status/changed' && codexThreadStoppedRunning(params)) { + release() + return + } + const turnId = readCodexTurnId(params) + if (turnId && (method === 'turn/started' || method === 'turn/completed')) { + release(turnId) + } + }, + releaseAll: () => release() + } +} diff --git a/src/main/codex/codex-structured-turn-processes.integration.test.ts b/src/main/codex/codex-structured-turn-processes.integration.test.ts deleted file mode 100644 index 2a63c8099ea..00000000000 --- a/src/main/codex/codex-structured-turn-processes.integration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { describe, expect, it } from 'vitest' -import { - captureCodexTurnProcesses, - terminateCodexTurnProcesses -} from './codex-structured-turn-processes' - -function nextLine(child: ChildProcessWithoutNullStreams): Promise { - return new Promise((resolve, reject) => { - let buffer = '' - const onData = (chunk: Buffer): void => { - buffer += chunk.toString('utf8') - const newline = buffer.indexOf('\n') - if (newline === -1) { - return - } - child.stdout.off('data', onData) - resolve(buffer.slice(0, newline)) - } - child.once('error', reject) - child.stdout.on('data', onData) - }) -} - -function processExists(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch { - return false - } -} - -describe.runIf(process.platform !== 'win32')('Codex structured turn process termination', () => { - it('removes the exact PID of a stopped 60-second command', async () => { - const root = spawn( - process.execPath, - [ - '-e', - `const { spawn } = require('node:child_process'); - process.stdin.once('data', () => { - const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); - process.stdout.write(String(child.pid) + '\\n'); - }); - setTimeout(() => {}, 60000);` - ], - { stdio: ['pipe', 'pipe', 'pipe'] } - ) - let commandPid = 0 - try { - const baseline = await captureCodexTurnProcesses(root.pid!) - root.stdin.write('start\n') - commandPid = Number(await nextLine(root)) - expect(processExists(commandPid)).toBe(true) - - await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true) - expect(processExists(commandPid)).toBe(false) - } finally { - if (commandPid > 0 && processExists(commandPid)) { - process.kill(commandPid, 'SIGKILL') - } - root.kill('SIGKILL') - } - }, 15_000) - - it('preserves descendants that predate the turn', async () => { - const root = spawn( - process.execPath, - [ - '-e', - `const { spawn } = require('node:child_process'); - const persistent = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); - process.stdout.write(String(persistent.pid) + '\\n'); - process.stdin.once('data', () => { - const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); - process.stdout.write(String(child.pid) + '\\n'); - }); - setTimeout(() => {}, 60000);` - ], - { stdio: ['pipe', 'pipe', 'pipe'] } - ) - let persistentPid = 0 - let commandPid = 0 - try { - persistentPid = Number(await nextLine(root)) - const baseline = await captureCodexTurnProcesses(root.pid!) - root.stdin.write('start\n') - commandPid = Number(await nextLine(root)) - - await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true) - expect(processExists(persistentPid)).toBe(true) - expect(processExists(commandPid)).toBe(false) - } finally { - if (persistentPid > 0 && processExists(persistentPid)) { - process.kill(persistentPid, 'SIGKILL') - } - if (commandPid > 0 && processExists(commandPid)) { - process.kill(commandPid, 'SIGKILL') - } - root.kill('SIGKILL') - } - }, 15_000) -}) diff --git a/src/main/codex/codex-structured-turn-processes.ts b/src/main/codex/codex-structured-turn-processes.ts deleted file mode 100644 index 163cbb098fb..00000000000 --- a/src/main/codex/codex-structured-turn-processes.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' -import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' -import { queryWindowsProcessDescendants } from '../providers/windows-foreground-process-rows' -import { terminateWindowsProcessTree } from '../windows-process-tree-kill' - -export type CodexTurnProcessSnapshot = - | { platform: 'posix'; snapshot: DescendantSnapshot } - | { platform: 'win32'; identities: ReadonlyMap } - -function windowsIdentity(row: { - ppid: number - name: string - command: string - executablePath?: string -}): string { - return [row.ppid, row.name, row.command, row.executablePath ?? ''].join('\0') -} - -export async function captureCodexTurnProcesses( - rootPid: number -): Promise { - if (process.platform === 'win32') { - const descendants = await queryWindowsProcessDescendants(rootPid, { fresh: true }) - return descendants - ? { - platform: 'win32', - identities: new Map(descendants.map((row) => [row.pid, windowsIdentity(row)])) - } - : null - } - const snapshot = await captureDescendantSnapshot(rootPid) - return snapshot ? { platform: 'posix', snapshot } : null -} - -function addedPosixDescendants( - baseline: DescendantSnapshot, - current: DescendantSnapshot -): DescendantSnapshot { - const baselineRows = new Map(baseline.descendants.map((row) => [row.pid, row])) - return { - ...current, - descendants: current.descendants.filter((row) => { - const prior = baselineRows.get(row.pid) - return prior?.startedAt !== row.startedAt || prior.pgid !== row.pgid - }) - } -} - -async function terminateWindowsAddedProcesses( - rootPid: number, - baseline: ReadonlyMap -): Promise { - const current = await queryWindowsProcessDescendants(rootPid, { fresh: true }) - if (!current) { - return false - } - const added = current.filter((row) => baseline.get(row.pid) !== windowsIdentity(row)) - const addedPids = new Set(added.map((row) => row.pid)) - const roots = added.filter((row) => !addedPids.has(row.ppid)) - // Added roots come from a table walk, not a spawn, so a refused tree walk has - // no handle to fall back to: the row stays in `remaining` and this reports false. - await Promise.all( - roots.map((row) => terminateWindowsProcessTree(row.pid, { site: 'codex-turn-added-roots' })) - ) - const targetIdentities = new Map(added.map((row) => [row.pid, windowsIdentity(row)])) - const remaining = await queryWindowsProcessDescendants(rootPid, { fresh: true }) - return ( - remaining !== null && - remaining.every((row) => targetIdentities.get(row.pid) !== windowsIdentity(row)) - ) -} - -export async function terminateCodexTurnProcesses( - rootPid: number, - baseline: CodexTurnProcessSnapshot | null -): Promise { - if (!baseline) { - return false - } - if (baseline.platform === 'win32') { - return terminateWindowsAddedProcesses(rootPid, baseline.identities) - } - const current = await captureDescendantSnapshot(rootPid) - if (!current) { - return false - } - const added = addedPosixDescendants(baseline.snapshot, current) - return terminateDescendantSnapshotAndWait(added) -} diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 939d1b90331..87d9327c70c 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -94,17 +94,12 @@ export class CodexSubagentExecutions { return { child, execution } } - /** A child turn that ended with no `turn/completed`. With no turn named, the one the child is - * running ended; a child running none has nothing to end. The first ending a turn gets stands. */ - endTurn( - agentThreadId: string, - turnId: string | null, - state: Exclude - ): void { + /** The child's thread closed with no `turn/completed`: the turn it was running ended, and how + * it went is unknown. A child running none has nothing to end. */ + closeThread(agentThreadId: string): void { const current = this.children.get(agentThreadId)?.execution - const ended = turnId ?? (current?.state === 'working' ? current.turnId : null) - if (current && ended !== null) { - this.observeTurn(agentThreadId, ended, state) + if (current?.state === 'working') { + this.observeTurn(agentThreadId, current.turnId, 'unverifiable') } } diff --git a/src/main/codex/codex-subagent-linkage.ts b/src/main/codex/codex-subagent-linkage.ts index 59d57a8e4c0..52f6bdc2611 100644 --- a/src/main/codex/codex-subagent-linkage.ts +++ b/src/main/codex/codex-subagent-linkage.ts @@ -8,15 +8,18 @@ // run are learned from the roster's executions, and a later revision of the // row picks them up when they arrive. -import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types' +import type { + AgentJournalProducerLinkage, + AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import type { CodexSubagentExecutions } from './codex-subagent-executions' -/** Linkage for a row one thread produced, within one of that thread's turns - * (null outside any). Every Codex write site resolves through this. */ -export type CodexRowLinkage = ( +/** Who produced a row one thread wrote within one of that thread's turns (null outside any), + * and which turn the row belongs to. Every Codex write site resolves through this. */ +export type CodexRowAttribution = ( threadId: string, turnId: string | null -) => AgentJournalProducerLinkage +) => AgentJournalRowAttribution export class CodexSubagentLinkage { constructor( @@ -26,7 +29,7 @@ export class CodexSubagentLinkage { } ) {} - linkageFor: CodexRowLinkage = (threadId, turnId) => { + linkageFor = (threadId: string, turnId: string | null): AgentJournalProducerLinkage => { const primary = this.deps.primaryThreadId() // An unknown primary means the session's thread is still opening, and no // turn has run that could have spawned a child. diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts new file mode 100644 index 00000000000..02c79411b79 --- /dev/null +++ b/src/main/codex/codex-subagent-roster-state.ts @@ -0,0 +1,36 @@ +// One spawn group's roster state, and the ids its journal row is keyed on. + +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +/** The turn a group belongs to when Codex reports activity outside any turn. + * Mirrors the generic-frame bucket name so the two read alike in the journal. */ +export type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope + /** Insertion order is the display order; the map holds the state. */ + entries: Map + executionTurns: Map + /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ + labelCounts: Map + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +/** Group identity: the parent turn that spawned the children. `agentPath` is a + * tree rooted at the parent thread, so every child of one turn shares a row + * no matter which thread's stream carried its activity item. */ +export function codexSubagentGroupId(threadId: string, turnId: string | null): string { + return `${threadId}:${turnId ?? 'outside-turn'}` +} + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 9d3e2c690c4..dcaecb0e3e5 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' import type { @@ -43,6 +44,7 @@ function createHarness(options: { threadId?: string | null } = {}): { tryPublish: () => ({ accepted: true }) } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), activeTurn: () => TURN, @@ -138,6 +140,7 @@ function createCoalescingHarness(): { } } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => THREAD, activeTurn: () => TURN, @@ -668,6 +671,7 @@ describe('CodexSubagentRoster', () => { const published: number[] = [] const refusal = { accepted: false, reason: 'backpressure' } as const const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -721,6 +725,7 @@ describe('CodexSubagentRoster', () => { const appended: Appended[] = [] const published: number[] = [] const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -765,6 +770,7 @@ describe('CodexSubagentRoster', () => { it('propagates sink backpressure instead of reporting the row as written', () => { const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 99b37e9c772..db02ac7f454 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -13,12 +13,9 @@ // thread, and a real turn id is assumed freshly minted per turn. Seeding from // the journal is the fix. -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../shared/native-chat-types' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -40,6 +37,12 @@ import { readCodexTurnId } from './codex-structured-thread-facts' import { codexSubagentGroupBody } from './codex-subagent-group-body' import { CodexSubagentLinkage } from './codex-subagent-linkage' export { codexSubagentGroupBody } from './codex-subagent-group-body' +export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' +import { + codexSubagentGroupId, + codexSubagentGroupIdentity, + type RosterGroup +} from './codex-subagent-roster-state' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, @@ -49,38 +52,12 @@ import { const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } -/** The turn a group belongs to when Codex reports activity outside any turn. - * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -type RosterGroup = { - groupId: string - identity: AgentJournalItemIdentity - /** Insertion order is the display order; the map holds the state. */ - entries: Map - executionTurns: Map - /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ - labelCounts: Map - /** Last body written, so an idempotent replay writes no new revision. */ - lastSerialized: string | null -} - -/** Group identity: the parent turn that spawned the children. `agentPath` is a - * tree rooted at the parent thread, so every child of one turn shares a row - * no matter which thread's stream carried its activity item. */ -export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? 'outside-turn'}` -} - -/** Durable journal identity for the group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } -} - export type CodexSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The thread that owns the agent tree; falls back to the event's thread. */ primaryThreadId: () => string | null activeTurn: (threadId: string) => string | null + turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope now?: () => number executions?: CodexSubagentExecutions } @@ -280,6 +257,7 @@ export class CodexSubagentRoster { const group: RosterGroup = { groupId, identity: codexSubagentGroupIdentity(groupId), + turnScope: this.deps.turnScopeFor(ownerThreadId, ownerTurnId), entries: new Map(), executionTurns: new Map(), labelCounts: new Map(), @@ -361,7 +339,10 @@ export class CodexSubagentRoster { // The publish must NOT reuse that key: the queue coalesces by key alone, // with no op-kind check, so a publish carrying it would splice out the // still-queued append and the row would never reach the journal. - const options = { coalescingKey: `codex-subagents:${group.groupId}` } + const options = { + coalescingKey: `codex-subagents:${group.groupId}`, + turnScope: group.turnScope + } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(group.identity, body, options) : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts index d08d7a54969..0159ce608d9 100644 --- a/src/main/codex/codex-trust-config-concurrent-launch.test.ts +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -25,6 +25,7 @@ const { CodexAppServerUnsupportedError } = await import('./codex-app-server-clie const { codexAppServerCapabilityCache } = await import('./codex-app-server-capability-cache') const { _internals, grantManagedCodexHookTrust } = await import('./codex-hook-trust-grant') const { markCodexProjectTrusted } = await import('../agent-trust-presets') +const { getLocalCodexTrustConfigFiles } = await import('./codex-home-paths') const { setCodexTrustGrantTelemetry } = await import('./codex-trust-grant-telemetry') const { computeTrustKey, @@ -207,7 +208,7 @@ describe('two Codex pane launches against one config.toml', () => { // Let the grant capture config.toml and start its session. await tick() await tick() - const marked = markCodexProjectTrusted(workspace) + const marked = markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) await tick() // The lane must hold the preset write back until rollback has run. expect(readFileSync(tomlPath, 'utf-8')).not.toContain('trust_level') @@ -397,7 +398,7 @@ describe('reentrancy under concurrency', () => { // write nested inside both. const outcome = await runExclusivelyForCodexTrustConfig(tomlPath, () => runExclusivelyForCodexTrustConfig(systemToml, async () => { - await markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) return grantManagedCodexHookTrust(buildPlan(entries)) }) ) diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index 9228fad43ce..d7fb8e45975 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -1,6 +1,5 @@ import { runProcess } from '../../shared/child-process/run-process' import { resolveCodexCommand } from '../codex-cli/command' -import { getSpawnArgsForWindows } from '../win32-utils' import { buildWslCodexAppServerArgs, buildWslCodexIdentityProbe, @@ -73,12 +72,11 @@ export function resolveNativeCodexTrustGrantHost(): ResolvedCodexTrustGrantHost return { binaryStamp: command === 'codex' ? null : buildNativeCodexBinaryStamp(command), buildRequest: (input) => { - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) const useDefaultCodexHome = input.useDefaultCodexHome === true return { invocation: { - command: spawnCmd, - args: spawnArgs, + command, + args: ['app-server'], cliPath: command, ...(useDefaultCodexHome ? { envToDelete: ['CODEX_HOME'] } diff --git a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts b/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts deleted file mode 100644 index 4b88d95991c..00000000000 --- a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Ratchet for stablyai/orca#16441. - * - * Codex hook trust used to be granted by blocking the Electron main thread on - * `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry, for the whole - * app-server deadline: 15s native, 35s WSL, ~45s on the three-session real-home - * path. The window showed "Not Responding" during cold start and pane launch. - * - * The subprocess only ever existed to donate an event loop to a deliberately - * blocked parent, so this guards the shape of the fix rather than one call - * site: nothing on the trust-grant lane may start a child process - * synchronously, and the forked entry must stay gone. - */ -const CODEX_DIR = __dirname - -const SYNC_SPAWN_PATTERN = /\b(?:spawnSync|execSync|execFileSync|runProcessSync)\s*[(<]/ - -/** Drop comments so the prose explaining the old idiom is not an offender. */ -function codeText(contents: string): string { - return contents - .split('\n') - .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) - .join('\n') -} - -function listCodexSourceFiles(): string[] { - return readdirSync(CODEX_DIR).filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) -} - -describe('codex trust grant main-thread boundary', () => { - it('starts no child process synchronously anywhere in the codex module', () => { - const offenders = listCodexSourceFiles().filter((name) => - SYNC_SPAWN_PATTERN.test(codeText(readFileSync(join(CODEX_DIR, name), 'utf8'))) - ) - expect(offenders).toEqual([]) - }) - - it('keeps the forked grant entry and its blocking bridge deleted', () => { - for (const name of [ - 'codex-app-server-grant-bridge.ts', - 'codex-app-server-grant-entry.ts', - 'codex-app-server-grant-envelope.ts' - ]) { - expect(existsSync(join(CODEX_DIR, name))).toBe(false) - } - }) - - it('keeps the trust-grant lane on async entry points', () => { - const grant = readFileSync(join(CODEX_DIR, 'codex-hook-trust-grant.ts'), 'utf8') - expect(grant).toContain('export async function grantManagedCodexHookTrust(') - const host = readFileSync(join(CODEX_DIR, 'codex-trust-grant-host.ts'), 'utf8') - expect(host).toContain('export async function resolveCodexTrustGrantHost(') - const realHome = readFileSync(join(CODEX_DIR, 'codex-real-home-hook-install.ts'), 'utf8') - expect(realHome).toContain('}): Promise {') - }) -}) diff --git a/src/main/codex/config-toml-trust-api-parity.test.ts b/src/main/codex/config-toml-trust-stale-writes.test.ts similarity index 59% rename from src/main/codex/config-toml-trust-api-parity.test.ts rename to src/main/codex/config-toml-trust-stale-writes.test.ts index d20cde89b8e..6a8fb8398de 100644 --- a/src/main/codex/config-toml-trust-api-parity.test.ts +++ b/src/main/codex/config-toml-trust-stale-writes.test.ts @@ -1,12 +1,6 @@ -import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { chmodSync, readFileSync, readdirSync, statSync } from 'node:fs' import * as trustApi from './config-toml-trust' -import type { - CodexEventLabel, - CodexHookTrustState, - CodexProjectTrustLevel, - CodexTrustEntry -} from './config-toml-trust' import { createTrustConfigFixture, removeTrustConfigFixture @@ -20,42 +14,6 @@ afterEach(() => { } }) -describe('config-toml-trust public API', () => { - it('retains the exact runtime export surface', () => { - expect(Object.keys(trustApi).sort()).toEqual( - [ - 'codexHookSourcePathsEqual', - 'computeTrustKey', - 'computeTrustedHash', - 'escapeTomlString', - 'getCodexExplicitHomeHookSourcePath', - 'normalizeCodexHookSourcePath', - 'normalizeCodexProjectPathForLookup', - 'normalizeCodexProjectPathForRevocationLookup', - 'normalizeHookTrustKeyForLookup', - 'parseCodexProjectHeaderPath', - 'parseTrustKey', - 'readHookTrustEntries', - 'readHookTrustEntriesFromContent', - 'removeHookTrustEntries', - 'removeHookTrustEntriesFromContent', - 'upsertHookTrustEntries', - 'upsertHookTrustEntriesInContent', - 'upsertProjectTrustLevel', - 'upsertProjectTrustLevelInContent', - 'writeConfigAtomically' - ].sort() - ) - }) - - it('retains the public type contracts', () => { - expectTypeOf<'stop' | 'session_start'>().toMatchTypeOf() - expectTypeOf().toEqualTypeOf<'trusted' | 'untrusted'>() - expectTypeOf().toHaveProperty('sourcePath').toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - }) -}) - describe('config.toml partial and stale writes', () => { it('preserves a truncated malformed prefix while appending trust', () => { const partial = ['model = "gpt-5"', '[mcp_servers.partial', 'command = "still-user-data'].join( diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index ba03d835231..c052ad2f158 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -29,17 +29,6 @@ type HooksConfig = { hooks: Record } -const managedEvents = [ - 'SessionStart', - 'UserPromptSubmit', - 'PreToolUse', - 'PermissionRequest', - 'PostToolUse', - 'SubagentStart', - 'SubagentStop', - 'Stop' -] as const - let tempRoots: string[] = [] afterEach(() => { @@ -511,7 +500,6 @@ describe('Codex WSL runtime hook install', () => { expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig - expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command expect(managedCommand).toBe(expectedManagedCommand(plan.commandScriptPath)) expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) diff --git a/src/main/crash-reporting/expected-teardown-state.test.ts b/src/main/crash-reporting/expected-teardown-state.test.ts index ccfc729092b..781ccce6b33 100644 --- a/src/main/crash-reporting/expected-teardown-state.test.ts +++ b/src/main/crash-reporting/expected-teardown-state.test.ts @@ -35,11 +35,6 @@ afterEach(() => { }) describe('expected teardown state', () => { - it('uses a product-chosen five-second harm bound, not a Windows lifetime guarantee', () => { - // Restart Manager may wait 30s; tree-kills after this bound remain reportable by design. - expect(WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS).toBe(5_000) - }) - it('uses the production monotonic clock by default', () => { vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValue(1_001) vi.spyOn(Date, 'now').mockReturnValueOnce(10_000).mockReturnValue(15_000) diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 1982facd5f3..09c4d3d7658 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { buildGpuCrashDiagnostics, GpuCrashDiagnosticsRecorder } from './gpu-crash-diagnostics' @@ -223,29 +221,3 @@ describe('GpuCrashDiagnosticsRecorder', () => { expect(recordBreadcrumb).toHaveBeenCalledWith({ gpuInfoLevel: 'unavailable' }) }) }) - -describe('GPU crash diagnostics production wiring', () => { - it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync( - join(__dirname, '..', 'startup', 'main-process-preflight.ts'), - 'utf8' - ) - const listenerSource = readFileSync( - join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), - 'utf8' - ) - const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") - expect(listenerStart).toBeGreaterThan(0) - const listener = listenerSource.slice( - listenerStart, - listenerSource.indexOf('\n })', listenerStart) - ) - expect(source).toMatch( - /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ - ) - expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ - ) - expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) - }) -}) diff --git a/src/main/crash-reporting/gpu-crash-fallback-decision.test.ts b/src/main/crash-reporting/gpu-crash-fallback-decision.test.ts index b4c4e1210ed..7dda535d0af 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-decision.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-decision.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' import { - DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, - DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, GpuCrashFallbackTracker, isGpuChildProcessType, isGpuFallbackCrashCandidate @@ -121,11 +119,6 @@ describe('GpuCrashFallbackTracker', () => { expect(isGpuChildProcessType('Utility')).toBe(false) expect(isGpuChildProcessType(undefined)).toBe(false) }) - - it('ships conservative defaults', () => { - expect(DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS).toBe(30_000) - expect(DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD).toBe(3) - }) }) describe('isGpuFallbackCrashCandidate', () => { diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index 43441250708..4454bc48457 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -1,55 +1,9 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate + GpuCrashFallbackTracker } from './gpu-crash-fallback-decision' -import { shouldRecordProcessGoneCrash } from './process-gone-classification' - -/** - * Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster - * through the production decision path, so the reason no host was ever offered - * Safe Graphics Mode is pinned rather than argued about. - * - * Each session below is one crash report's `Recent activity`; a breadcrumb's - * `suppressedSinceLast=N` means N further identical GPU deaths were coalesced - * into it, so the session saw N+1 GPU crashes. - */ - -type FieldSession = { - /** Crash-report id from the field bundle. */ - report: string - /** GPU child crash times, ms since main-process start. */ - gpuCrashesMsSinceLaunch: number[] -} - -// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts - -// mainProcessStartedAt); coalesced repeats are placed inside the same second, -// which is the only interval the emitted breadcrumb pins them to. -const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ - // 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes - { report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] }, - // 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash - { report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] }, - // 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes - { report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] }, - // 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash - { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } -] - -/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ -function readChildProcessGoneListener(): string { - const source = readFileSync( - join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), - 'utf8' - ) - const start = source.indexOf(" app.on('child-process-gone'") - expect(start).toBeGreaterThan(0) - return source.slice(start, source.indexOf('\n })', start)) -} function newTracker(): GpuCrashFallbackTracker { return new GpuCrashFallbackTracker({ @@ -58,73 +12,7 @@ function newTracker(): GpuCrashFallbackTracker { }) } -const FIELD_GPU_EVENT = { - source: 'child', - processType: 'GPU', - serviceName: 'GPU', - reason: 'crashed', - expectedTeardown: 'none' -} as const - describe('1.4.190 win32 GPU-child crash cluster', () => { - it('does not let process_gone_suppressed gate the fallback candidate check', () => { - // The GPU death is suppressed as recoverable churn (no user-facing report)... - expect( - shouldRecordProcessGoneCrash({ - ...FIELD_GPU_EVENT, - platform: 'win32', - exitCode: -2147483645 - }) - ).toBe(false) - // ...but the fallback path reads the raw child-process-gone event, so the - // suppression cannot hide a broken driver from recovery. - expect( - isGpuFallbackCrashCandidate({ - platform: 'win32', - processType: 'GPU', - reason: 'crashed' - }) - ).toBe(true) - // Both assertions above still pass if the candidate check is moved behind the - // suppressed-report path, so pin that nothing branches ahead of it in index.ts. - const listener = readChildProcessGoneListener() - const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(') - expect(guardStart).toBeGreaterThan(0) - expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) - expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ - ) - // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which - // re-couples recovery to the suppression decision, so pin the guard to that check alone. - const recoveryGuard = listener.slice( - listener.lastIndexOf('if (', guardStart), - listener.indexOf('handleGpuChildCrash(') - ) - expect(recoveryGuard).not.toMatch(/&&|\|\|/) - }) - - it('never reaches the burst threshold in any observed cluster session', () => { - const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => { - // Each launch constructs a fresh tracker (src/main/index.ts), so evidence - // does not survive the relaunch these users performed after every crash. - const tracker = newTracker() - const engaged = session.gpuCrashesMsSinceLaunch.some( - (at) => tracker.recordGpuCrash(at).shouldEngageFallback - ) - return { - report: session.report, - gpuCrashes: session.gpuCrashesMsSinceLaunch.length, - engagedSafeGraphicsPrompt: engaged - } - }) - expect(outcomes).toEqual([ - { report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, - { report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }, - { report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, - { report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false } - ]) - }) - it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => { // oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then // `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 23707754ff8..dd918ba9397 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { showMessageBoxMock } = vi.hoisted(() => ({ @@ -105,22 +103,3 @@ describe('handleGpuFallbackRecoveredLaunch', () => { expect(handlers.restartWithHardware).not.toHaveBeenCalled() }) }) - -describe('recovered safe-graphics production wiring', () => { - it('prompts only after the recovered window is shown and persists both consent states', () => { - const windowSource = readFileSync( - join(__dirname, '..', 'startup', 'main-window-controller.ts'), - 'utf8' - ) - const lifecycleSource = readFileSync( - join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), - 'utf8' - ) - expect(windowSource).toMatch( - /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ - ) - expect(lifecycleSource).toMatch( - /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ - ) - }) -}) diff --git a/src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts b/src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts index eab933e9b8f..1b096cd5ea2 100644 --- a/src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts +++ b/src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, - DEFAULT_RENDERER_RECOVERY_WINDOW_MS, - RendererRecoveryCircuitBreaker -} from './renderer-recovery-circuit-breaker' +import { RendererRecoveryCircuitBreaker } from './renderer-recovery-circuit-breaker' describe('RendererRecoveryCircuitBreaker', () => { it('allows recoveries up to the limit, then opens', () => { @@ -57,9 +53,4 @@ describe('RendererRecoveryCircuitBreaker', () => { breaker.reset() expect(breaker.registerRecoveryAttempt(200).allowed).toBe(true) }) - - it('ships conservative defaults', () => { - expect(DEFAULT_RENDERER_RECOVERY_WINDOW_MS).toBe(60_000) - expect(DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES).toBe(3) - }) }) diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts index e958dac42f8..c9a2a5cdf03 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts @@ -124,7 +124,7 @@ describe('self-initiated tree kill breadcrumb', () => { ;(done as () => void)() return undefined as never }) as never, - site: 'codex-turn-added-roots' + site: 'codex-app-server-teardown' }) expect(getCrashBreadcrumbSnapshot()).toEqual([ @@ -132,7 +132,7 @@ describe('self-initiated tree kill breadcrumb', () => { name: 'self_tree_kill', data: expect.objectContaining({ pid: 777, - site: 'codex-turn-added-roots', + site: 'codex-app-server-teardown', scope: 'win-taskkill-tree' }) }) diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.ts index 701cb2d646e..d684bd874ef 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.ts @@ -60,8 +60,8 @@ export type SelfInitiatedTreeKill = { at: number } -// Why 32 and not the sibling ring's 16: one teardown fans out over every root of -// a codex turn, so a single incident can spend a dozen entries on its own. +// Why 32 and not the sibling ring's 16: a window close tears down every session at once, +// a burst of 30+ group kills (see the eviction note below). const MAX_TRACKED_SELF_KILLS = 32 // Why asymmetric: a kill older than this cannot plausibly explain the death, diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 2d7973bae88..31ee0583e55 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -67,7 +67,6 @@ describe('DaemonClient', () => { closeOnHello?: boolean onControlMessage?: (msg: unknown) => string | null onHello?: (msg: HelloMessage) => void - onStreamHello?: (msg: HelloMessage) => void rejectVersion?: boolean suppressHelloResponse?: boolean omitHelloIdentity?: boolean @@ -127,9 +126,6 @@ describe('DaemonClient', () => { : {}) }) ) - if (hello.role === 'stream') { - opts?.onStreamHello?.(hello) - } } else if (opts?.onControlMessage) { const response = opts.onControlMessage(msg) if (response) { @@ -145,20 +141,6 @@ describe('DaemonClient', () => { } describe('connect', () => { - it('establishes connection with hello handshake', async () => { - const hellos: HelloMessage[] = [] - await startMockDaemon({ - onStreamHello: (msg) => hellos.push(msg) - }) - - client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - - expect(client.isConnected()).toBe(true) - // Both control and stream sockets should have sent hello - await waitFor(() => hellos.length > 0) - }) - it('captures one matching endpoint identity from both authenticated sockets', async () => { const identity = { pid: 123, @@ -514,50 +496,6 @@ describe('DaemonClient', () => { }) describe('events', () => { - it('receives stream events', async () => { - let streamSocket: Socket | null = null - await startMockDaemon({ - onStreamHello: () => { - // We need to capture the stream socket to send events on it - } - }) - - // Capture stream socket from server - const origListener = server.listeners('connection')[0] as (s: Socket) => void - server.removeAllListeners('connection') - let socketCount = 0 - server.on('connection', (socket) => { - socketCount++ - if (socketCount === 2) { - streamSocket = socket - } - origListener(socket) - }) - - const events: DaemonEvent[] = [] - client = new DaemonClient({ socketPath, tokenPath }) - client.onEvent((event) => events.push(event as DaemonEvent)) - await client.ensureConnected() - - await waitFor(() => streamSocket !== null) - - // Send a data event on the stream socket - const event: DaemonEvent = { - type: 'event', - event: 'data', - sessionId: 'session-1', - payload: { data: 'hello from daemon' } - } - streamSocket!.write(encodeNdjson(event)) - - await waitFor(() => events.length > 0) - expect(events[0]).toMatchObject({ - type: 'event', - event: 'data', - sessionId: 'session-1' - }) - }) - it('preserves UTF-8 stream events split inside multibyte characters', async () => { let streamSocket: Socket | null = null await startMockDaemon() diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts index 67458bd66fe..fc493ea75ff 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.test.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -206,15 +206,6 @@ describe('trackDaemonPtyCwdVerdict', () => { ).resolves.toBeUndefined() expect(trackMock).not.toHaveBeenCalled() }) - - it('swallows a throwing telemetry client', async () => { - trackMock.mockImplementationOnce(() => { - throw new Error('posthog exploded') - }) - await expect( - trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH) - ).resolves.toBeUndefined() - }) }) describe('reportDaemonPtyCwdVerdict', () => { diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index f7834dba9d7..76f05e9ddf5 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,5 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' -import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' diff --git a/src/main/daemon/daemon-checkpoint-session-queue.test.ts b/src/main/daemon/daemon-checkpoint-session-queue.test.ts index 158d1891f9a..08e440a133b 100644 --- a/src/main/daemon/daemon-checkpoint-session-queue.test.ts +++ b/src/main/daemon/daemon-checkpoint-session-queue.test.ts @@ -173,25 +173,6 @@ describe('CheckpointSessionQueue', () => { expect(queue.isSaturated('s')).toBe(false) }) - it('keeps a stalled session queued rather than admitting a second writer', async () => { - const queue = new CheckpointSessionQueue() - const stalled = deferred() - let secondEntered = false - - void queue.run('s', async () => await stalled.promise).catch(() => {}) - const second = queue.run('s', async () => { - secondEntered = true - }) - - // Why this matters: two concurrent tmp-write/rename pairs in one session directory lose a - // checkpoint, so a parked write must hold its slot rather than be dropped. - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(secondEntered).toBe(false) - stalled.resolve() - await second - expect(secondEntered).toBe(true) - }) - it('does not strand a session after an operation rejects', async () => { const queue = new CheckpointSessionQueue() await expect( diff --git a/src/main/daemon/daemon-color-query-reply-colors.test.ts b/src/main/daemon/daemon-color-query-reply-colors.test.ts new file mode 100644 index 00000000000..ff604b3e39a --- /dev/null +++ b/src/main/daemon/daemon-color-query-reply-colors.test.ts @@ -0,0 +1,73 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { rmSync } from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { _resetPtyOwnerHostColorsForTest } from '../../shared/pty-owner-color-query-colors' +import { + createMockSubprocess, + startDaemonAdapterHarness, + waitFor, + type DaemonAdapterHarness +} from './daemon-pty-adapter-test-harness' + +const QUERY = '\x1b]11;?\x07' + +describe('daemon OSC 10/11 colours', () => { + let harness: DaemonAdapterHarness + let subprocess: ReturnType + let secondAdapter: DaemonPtyAdapter | null = null + + beforeEach(async () => { + harness = await startDaemonAdapterHarness(() => { + subprocess = createMockSubprocess() + return subprocess + }) + }) + + afterEach(async () => { + secondAdapter?.dispose() + secondAdapter = null + harness.adapter.dispose() + await harness.server.shutdown() + rmSync(harness.dir, { recursive: true, force: true }) + _resetPtyOwnerHostColorsForTest() + }) + + it('answers from the default theme until colours are pushed, then from the latest push', async () => { + await harness.adapter.spawn({ cols: 80, rows: 24 }) + + subprocess._simulateData(QUERY) + await waitFor(() => subprocess.write.mock.calls.length === 1) + harness.adapter.setColorQueryReplyColors({ foreground: '#000000', background: '#123456' }) + // Why a second query to wait on: the push is a fire-and-forget notification. + await waitFor(() => { + subprocess._simulateData(QUERY) + return subprocess.write.mock.calls.some( + ([reply]) => reply === '\x1b]11;rgb:1212/3434/5656\x1b\\' + ) + }) + + expect(subprocess.write.mock.calls[0]).toEqual(['\x1b]11;rgb:2828/2c2c/3434\x1b\\']) + }) + + it('resends the last colours when an adapter connects, so a push made while offline lands', async () => { + secondAdapter = new DaemonPtyAdapter({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath + }) + secondAdapter.setColorQueryReplyColors({ foreground: '#000000', background: '#abcdef' }) + await secondAdapter.spawn({ cols: 80, rows: 24 }) + + await waitFor(() => { + subprocess._simulateData(QUERY) + return subprocess.write.mock.calls.some( + ([reply]) => reply === '\x1b]11;rgb:abab/cdcd/efef\x1b\\' + ) + }) + // The value is daemon-wide: a session another client spawned answers the same way. + await harness.adapter.spawn({ cols: 80, rows: 24 }) + subprocess._simulateData(QUERY) + await waitFor(() => subprocess.write.mock.calls.length === 1) + expect(subprocess.write.mock.calls[0]).toEqual(['\x1b]11;rgb:abab/cdcd/efef\x1b\\']) + }) +}) diff --git a/src/main/daemon/daemon-endpoint-publish.test.ts b/src/main/daemon/daemon-endpoint-publish.test.ts index dc14adca3f7..0f3c178fe11 100644 --- a/src/main/daemon/daemon-endpoint-publish.test.ts +++ b/src/main/daemon/daemon-endpoint-publish.test.ts @@ -112,28 +112,6 @@ describe('publishDaemonEndpoint', () => { } }) - unixIt('replaces an incumbent that has stopped listening', async () => { - const directory = makeTempDir() - const canonicalPath = join(directory, 'd') - const incumbentPath = getDaemonSocketBindPath(canonicalPath) - const newcomerPath = getDaemonSocketBindPath(canonicalPath) - const incumbent = await listen(incumbentPath) - const newcomer = await listen(newcomerPath) - try { - await publishListener(incumbentPath, canonicalPath) - await close(incumbent.server) - - const outcome = await publishDaemonEndpoint(newcomerPath, canonicalPath, probeSocketConnect) - - expect(outcome).toMatchObject({ status: 'published' }) - await expectReachable(canonicalPath) - expect(newcomer.connections()).toBe(1) - } finally { - await Promise.all([close(incumbent.server), close(newcomer.server)]) - rmSync(directory, { recursive: true, force: true }) - } - }) - unixIt( 'never replaces a daemon that published while the death proof was being gathered', async () => { diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index 1ecf7c93714..4ae8800bc20 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -15,14 +15,6 @@ describe('daemon-entry parseArgs', () => { }) }) - it('handles flags in any order', () => { - const result = parseArgs(['--token', '/tmp/t.token', '--socket', '/tmp/t.sock']) - expect(result).toEqual({ - socketPath: '/tmp/t.sock', - tokenPath: '/tmp/t.token' - }) - }) - it('throws when --socket is missing', () => { expect(() => parseArgs(['--token', '/tmp/t.token'])).toThrow('Usage:') }) @@ -31,15 +23,6 @@ describe('daemon-entry parseArgs', () => { expect(() => parseArgs(['--socket', '/tmp/t.sock'])).toThrow('Usage:') }) - it('throws with no args', () => { - expect(() => parseArgs([])).toThrow('Usage:') - }) - - it('omits logFilePath when --log-file is absent (adopted old daemons)', () => { - const result = parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token']) - expect(result).not.toHaveProperty('logFilePath') - }) - it('parses --log-file when present', () => { const result = parseArgs([ '--socket', diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 142d1706f9c..ccca734ea70 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(38) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -19,5 +19,7 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(35) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index dfe84947ff5..ab62dc5736b 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -3,6 +3,8 @@ import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION, + CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION, + COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION, CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION, @@ -14,12 +16,15 @@ import { WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, + supportsColorQueryReplyColors, supportsMode2031UnsubscribeFact } from './daemon-protocol-version' describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(38) + expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38) + expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35) expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34) @@ -33,10 +38,15 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 35 }, (_, index) => index + 1) + Array.from({ length: 37 }, (_, index) => index + 1) ) }) + it('pushes host colours only to daemons that answer OSC 10/11 for life', () => { + expect(supportsColorQueryReplyColors(37)).toBe(false) + expect(supportsColorQueryReplyColors(38)).toBe(true) + }) + it('withholds 2031-unsubscribe support only before its v29 boundary', () => { // Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app // update is the live hazard — it emits '2031-subscribe' with no way to retract it. diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index bae2ae972dc..5ca840798e9 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,9 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v36 launches shells from content-addressed wrapper trees; older owners stay attachable. -export const PROTOCOL_VERSION = 36 +// v38 answers OSC 10/11 for each session's whole life from pushed host colours; older owners stay attachable. +export const PROTOCOL_VERSION = 38 +// Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. +export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 +export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36 export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35 export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34 @@ -30,9 +33,13 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37 ] as const +export function supportsColorQueryReplyColors(protocolVersion: number): boolean { + return protocolVersion >= COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION +} + export function supportsPtyStartupIngress(protocolVersion: number): boolean { return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION } diff --git a/src/main/daemon/daemon-pty-connection-lifecycle.ts b/src/main/daemon/daemon-pty-connection-lifecycle.ts index fa8153ad47a..88177e3b944 100644 --- a/src/main/daemon/daemon-pty-connection-lifecycle.ts +++ b/src/main/daemon/daemon-pty-connection-lifecycle.ts @@ -13,8 +13,17 @@ import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-inca import { notifyDaemonAuditListeners } from './daemon-listener-registry' import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions' import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' +import { supportsColorQueryReplyColors } from './daemon-protocol-version' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions { + private colorQueryReplyColors: TerminalOscColorQueryReplyColors | null = null + + setColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void { + this.colorQueryReplyColors = colors + this.syncColorQueryReplyColors() + } + protected async ensureConnected(deadlineMs?: number): Promise { try { // Why: destructive teardown bounds the handshake by its deadline so a wedged @@ -35,6 +44,14 @@ export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscri this.flushOwedProducerResumes() if (isFreshConnection) { this.resyncBackgroundedSessions() + // Why: a replacement daemon starts with no colours, and a disconnected push was dropped. + this.syncColorQueryReplyColors() + } + } + + private syncColorQueryReplyColors(): void { + if (this.colorQueryReplyColors && supportsColorQueryReplyColors(this.protocolVersion)) { + this.client.notify('setColorQueryReplyColors', { colors: this.colorQueryReplyColors }) } } diff --git a/src/main/daemon/daemon-pty-owner-query-protocol.ts b/src/main/daemon/daemon-pty-owner-query-protocol.ts new file mode 100644 index 00000000000..875f09c6c99 --- /dev/null +++ b/src/main/daemon/daemon-pty-owner-query-protocol.ts @@ -0,0 +1,12 @@ +export type CloseStartupQueryAuthorityRequest = { + id: string + type: 'closeStartupQueryAuthority' + payload: { sessionId: string } +} + +/** Daemon-wide viewer colours every session answers OSC 10/11 from (v38+). */ +export type SetColorQueryReplyColorsRequest = { + id: string + type: 'setColorQueryReplyColors' + payload: { colors: unknown } +} diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index c3330e61826..0534c9d2869 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -13,6 +13,7 @@ import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -170,6 +171,12 @@ export class DaemonPtyRouter implements IPtyProvider { return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0 } + setColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void { + for (const adapter of this.allAdapters()) { + adapter.setColorQueryReplyColors(colors) + } + } + acknowledgeDataEvent(id: string, charCount: number): void { this.adapterFor(id).acknowledgeDataEvent(id, charCount) } diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 9bd0d7963b6..d15514ea2ae 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -1,4 +1,5 @@ import { performance } from 'node:perf_hooks' +import { setPtyOwnerHostColors } from '../../shared/pty-owner-color-query-colors' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' import type { DaemonFileLog } from './daemon-file-log' @@ -70,6 +71,9 @@ export class DaemonRequestRouter { return { appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) } + case 'setColorQueryReplyColors': + setPtyOwnerHostColors(request.payload.colors) + return {} case 'write': return this.write(client, request.payload.sessionId, request.payload.data) case 'resize': diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index ef89b3ef3b0..3479b4eea45 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -437,23 +437,6 @@ describe('DaemonServer', () => { expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health) }) - it('handles write (fire-and-forget)', async () => { - await startServer() - const c = await connectClient() - - await c.request('createOrAttach', { - sessionId: 'test-session', - cols: 80, - rows: 24 - }) - - // Should not throw - c.notify('write', { sessionId: 'test-session', data: 'ls\n' }) - - // Give the server time to process - await new Promise((r) => setTimeout(r, 50)) - }) - it('handles resize', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index a2ad26130ff..a031ccd9e54 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createServer, connect, type Server } from 'node:net' import { DaemonSpawner, getDaemonArtifactHoldClaimPath, @@ -14,12 +13,7 @@ import { replaceDaemonPidFile, restoreClaimedDaemonArtifact } from './daemon-spawner' -import { - getDaemonSocketBindPath, - publishDaemonEndpoint, - readDaemonSocketIdentity -} from './daemon-endpoint-ownership' -import { probeSocketConnect } from './daemon-endpoint-probe' +import { getDaemonSocketBindPath } from './daemon-endpoint-ownership' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' import type { SubprocessHandle } from './session-subprocess-handle' @@ -144,25 +138,6 @@ describe('DaemonSpawner', () => { expect(client.isConnected()).toBe(true) client.disconnect() }) - - it('daemon can create sessions', async () => { - const s = createSpawner() - const info = await s.ensureRunning() - - const client = new DaemonClient({ - socketPath: info.socketPath, - tokenPath: info.tokenPath - }) - await client.ensureConnected() - - const result = await client.request<{ isNew: boolean }>('createOrAttach', { - sessionId: 'test-session', - cols: 80, - rows: 24 - }) - expect(result.isNew).toBe(true) - client.disconnect() - }) }) describe('shutdown', () => { @@ -313,45 +288,6 @@ describe('daemon PID publication', () => { }) }) -function listenOnSocketPath(server: Server, socketPath: string): Promise { - return new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, () => { - server.off('error', reject) - resolve() - }) - }) -} - -function closeSocketServer(server: Server): Promise { - return new Promise((resolve) => { - if (!server.listening) { - resolve() - return - } - server.close(() => resolve()) - }) -} - -function connectsToSocketPath(socketPath: string): Promise { - return new Promise((resolve) => { - const socket = connect({ path: socketPath }) - const timer = setTimeout(() => { - socket.destroy() - resolve(false) - }, 500) - socket.on('connect', () => { - clearTimeout(timer) - socket.destroy() - resolve(true) - }) - socket.on('error', () => { - clearTimeout(timer) - resolve(false) - }) - }) -} - describe('daemon socket publication', () => { it('keeps every scratch namespace outside the released sweeper pattern', () => { // Why pinned, and why all three: builds already in the field sweep these names on age alone @@ -380,92 +316,4 @@ describe('daemon socket publication', () => { expect(getDaemonSocketBindPath(canonicalPath).length).toBeLessThan(canonicalPath.length) }) - - it.skipIf(process.platform === 'win32')( - 'keeps a live incumbent reachable when a second listener publishes', - async () => { - const dir = createTestDir() - const canonicalPath = getDaemonSocketPath(dir) - const incumbent = createServer((socket) => socket.end()) - const newcomer = createServer((socket) => socket.end()) - try { - const incumbentBind = getDaemonSocketBindPath(canonicalPath) - await listenOnSocketPath(incumbent, incumbentBind) - const incumbentOutcome = await publishDaemonEndpoint( - incumbentBind, - canonicalPath, - probeSocketConnect - ) - expect(incumbentOutcome.status).toBe('published') - const incumbentIdentity = readDaemonSocketIdentity(canonicalPath) - - const newcomerBind = getDaemonSocketBindPath(canonicalPath) - await listenOnSocketPath(newcomer, newcomerBind) - await expect( - publishDaemonEndpoint(newcomerBind, canonicalPath, probeSocketConnect) - ).resolves.toEqual({ status: 'occupied' }) - - expect(readDaemonSocketIdentity(canonicalPath)).toEqual(incumbentIdentity) - await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true) - } finally { - await closeSocketServer(incumbent) - await closeSocketServer(newcomer) - rmSync(dir, { recursive: true, force: true }) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'leaves an unclassifiable incumbent untouched', - async () => { - const dir = createTestDir() - const canonicalPath = getDaemonSocketPath(dir) - const newcomer = createServer((socket) => socket.end()) - try { - writeFileSync(canonicalPath, 'incumbent') - const newcomerBind = getDaemonSocketBindPath(canonicalPath) - await listenOnSocketPath(newcomer, newcomerBind) - - await expect( - publishDaemonEndpoint(newcomerBind, canonicalPath, async () => 'unknown') - ).resolves.toEqual({ status: 'inconclusive' }) - expect(readFileSync(canonicalPath, 'utf8')).toBe('incumbent') - } finally { - await closeSocketServer(newcomer) - rmSync(dir, { recursive: true, force: true }) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'replaces a dead incumbent with a reachable listener', - async () => { - const dir = createTestDir() - const canonicalPath = getDaemonSocketPath(dir) - const incumbent = createServer((socket) => socket.end()) - const replacement = createServer((socket) => socket.end()) - try { - const incumbentBind = getDaemonSocketBindPath(canonicalPath) - await listenOnSocketPath(incumbent, incumbentBind) - await publishDaemonEndpoint(incumbentBind, canonicalPath, probeSocketConnect) - await closeSocketServer(incumbent) - await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(false) - - const replacementBind = getDaemonSocketBindPath(canonicalPath) - await listenOnSocketPath(replacement, replacementBind) - const outcome = await publishDaemonEndpoint( - replacementBind, - canonicalPath, - probeSocketConnect - ) - - expect(outcome.status).toBe('published') - await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true) - } finally { - await closeSocketServer(incumbent) - await closeSocketServer(replacement) - rmSync(dir, { recursive: true, force: true }) - } - } - ) }) diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index dfc7c394fdc..a562a60ee9d 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -7,7 +7,7 @@ import { releaseDaemonStreamEntry } from './daemon-stream-entry-accounting' import { DaemonStreamHeldRefill } from './daemon-stream-held-refill' -import { clampToSafeSplitIndex, writeStreamDataEvents } from './daemon-stream-data-split' +import { clampToSafeBulkWriteSplitIndex, writeStreamDataEvents } from './daemon-stream-data-split' import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import type { DaemonEvent } from './types' import { @@ -213,7 +213,7 @@ export class DaemonStreamDataBatcher { const end = entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS ? entry.data.length - : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) + : clampToSafeBulkWriteSplitIndex(entry.data, BULK_WRITE_SLICE_CHARS) const slice = entry.data.slice(0, end) const entrySequenceChars = entry.sequenceChars ?? entry.data.length const sliceSequenceChars = entry.transformed diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index 06ad09e1ded..c31c4437b84 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -3,6 +3,7 @@ * chunking (the receiver's parser rejects oversized lines) and the safe-index * clamp shared by the batcher's bulk write slicing and keep-tail dropping. */ +import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan' import { encodeNdjson } from './ndjson' export function encodeStreamDataEvent( @@ -38,6 +39,21 @@ function isLowSurrogate(value: number): boolean { return value >= 0xdc00 && value <= 0xdfff } +/** + * Bulk-write split policy: frame-align first so a held remainder cannot strand an + * open DEC 2026 frame's closing \x1b[?2026l (xterm then stops repainting until its + * 1s timeout), then let the surrogate clamp have the final say. + */ +export function clampToSafeBulkWriteSplitIndex(value: string, end: number): number { + // Math.max(1): the surrogate clamp can decrement an aligned index to 0 (e.g. + // ('\u{1F600}aaaa', 1)), and a 0-length slice would never shift the batcher's + // queue entry, spinning its drain loop. + return Math.max( + 1, + clampToSafeSplitIndex(value, 0, resolveSynchronizedOutputSafeSplit(value, end)) + ) +} + export function clampToSafeSplitIndex(value: string, start: number, end: number): number { if (end <= start || end >= value.length) { return end diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index 2da68eef53b..88e8af0b805 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -14,8 +14,7 @@ export function getDaemonZshWrapperSpec(): ZshStartupHookSpec { managedWslCli: false, agentTeamsPath: true, remoteCliBinDir: false, - codexHome: true, - codexLaunchPreflight: true + codexHome: true } } } diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 6db8c80ac87..0a8868a8d80 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -174,6 +174,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 } + setColorQueryReplyColors: IPtyProvider['setColorQueryReplyColors'] = (colors) => + this.allProviders().forEach((provider) => provider.setColorQueryReplyColors?.(colors)) + acknowledgeDataEvent(id: string, charCount: number): void { this.providerFor(id).acknowledgeDataEvent(id, charCount) } diff --git a/src/main/daemon/headless-emulator-modes.ts b/src/main/daemon/headless-emulator-modes.ts index bf65d527a3c..5ab7e2398ad 100644 --- a/src/main/daemon/headless-emulator-modes.ts +++ b/src/main/daemon/headless-emulator-modes.ts @@ -1,5 +1,5 @@ import type { Terminal } from '@xterm/headless' -import type { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' +import { readTerminalMouseEncoding } from '../../shared/terminal-mouse-encoding' import type { TerminalModes } from './types' type TerminalWithKittyKeyboard = Terminal & { @@ -13,18 +13,16 @@ export function readKittyKeyboardFlags(terminal: Terminal): number { } /** Mode state a snapshot must carry so a restored pane behaves like the live one. */ -export function readTerminalModes( - terminal: Terminal, - mouseModes: TerminalMouseModeMirror -): TerminalModes { +export function readTerminalModes(terminal: Terminal): TerminalModes { const buffer = terminal.buffer.active - const mouseTrackingMode = mouseModes.mouseTrackingMode + const mouseTrackingMode = terminal.modes.mouseTrackingMode + const mouseEncoding = readTerminalMouseEncoding(terminal) return { bracketedPaste: terminal.modes.bracketedPasteMode, mouseTracking: mouseTrackingMode !== 'none', mouseTrackingMode, - sgrMouseMode: mouseModes.sgrMouseMode, - sgrMousePixelsMode: mouseModes.sgrMousePixelsMode, + sgrMouseMode: mouseEncoding === 'sgr', + sgrMousePixelsMode: mouseEncoding === 'sgr-pixels', applicationCursor: buffer.type === 'normal' ? terminal.modes.applicationCursorKeysMode : false, alternateScreen: buffer.type === 'alternate', kittyKeyboardFlags: readKittyKeyboardFlags(terminal) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 849f78b8308..326bba56fb5 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -455,7 +455,8 @@ describe('HeadlessEmulator', () => { it('tracks long split private mouse mode sequences', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) - const fillerModes = Array.from({ length: 40 }, (_, i) => String(3000 + i)).join(';') + // Within xterm's 32-param cap: modes follow what xterm itself parsed, as every live viewer does. + const fillerModes = Array.from({ length: 29 }, (_, i) => String(3000 + i)).join(';') await emulator.write(`\x1b[?1002;${fillerModes};100`) await emulator.write('6h') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index a74502e251f..fa1cb054ee3 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -12,7 +12,6 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { readTerminalModes } from './headless-emulator-modes' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' -import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences' import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' @@ -60,7 +59,6 @@ export class HeadlessEmulator { protected terminal: Terminal protected serializer: SerializeAddon private oscText: TerminalOscCwdTitleScanner - private mouseModes = new TerminalMouseModeMirror() private readonly pathFlavor?: 'posix' | 'win32' private readonly remotePosixFileUriAuthority: boolean private restoredOscLinks: TerminalOscLinkRange[] = [] @@ -188,8 +186,6 @@ export class HeadlessEmulator { if (forwardQueryReplies) { this.queryReplyForwardingDepth -= 1 } - // Why: commit the mouse-mode mirror only after xterm has parsed the same bytes (snapshots combine both). - this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) @@ -222,7 +218,6 @@ export class HeadlessEmulator { this.queryReplyForwardingDepth -= 1 } } - this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } @@ -364,6 +359,6 @@ export class HeadlessEmulator { } private getModes(): TerminalModes { - return readTerminalModes(this.terminal, this.mouseModes) + return readTerminalModes(this.terminal) } } diff --git a/src/main/daemon/issue-6814-daemon-failure-classification.test.ts b/src/main/daemon/issue-6814-daemon-failure-classification.test.ts index a7504a5e2a3..b1c36a2c7f5 100644 --- a/src/main/daemon/issue-6814-daemon-failure-classification.test.ts +++ b/src/main/daemon/issue-6814-daemon-failure-classification.test.ts @@ -56,44 +56,6 @@ describe('issue #6814 repro: daemon failure-mode classification', () => { rmSync(dir, { recursive: true, force: true }) }) - // The good case: daemon answers hello AND the PTY spawn probe succeeds. - it('HEALTHY: a daemon that can spawn PTYs classifies as healthy', async () => { - const server = new DaemonServer({ - socketPath, - tokenPath, - ptySpawnHealthCheck: vi.fn(async () => {}), - spawnSubprocess: () => createMockSubprocess() - }) - await server.start() - try { - await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy') - } finally { - await server.shutdown() - } - }) - - // Symptom B, degraded: this is the case #6830 RESCUES. The daemon answers - // protocol but its PTY spawn probe throws (deleted cwd / stale native PTY - // after an upgrade), so fresh terminals would open frozen with no cursor. - it('DEGRADED: protocol-alive daemon that cannot spawn PTYs classifies as pty-spawn-unhealthy', async () => { - const server = new DaemonServer({ - socketPath, - tokenPath, - ptySpawnHealthCheck: vi.fn(async () => { - throw new Error('chdir(2) failed.: No such file or directory') - }), - spawnSubprocess: () => createMockSubprocess() - }) - await server.start() - try { - // -> #6830 marks this daemon degraded and routes fresh spawns to the - // local provider instead of the no-cursor daemon pane. - await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('pty-spawn-unhealthy') - } finally { - await server.shutdown() - } - }) - // The limit of #6830: a fully WEDGED daemon (event loop hung — health RPC // never returns) cannot be distinguished by a richer status. It times out // and classifies as 'unreachable', the SAME bucket as a dead daemon. @@ -137,9 +99,4 @@ describe('issue #6814 repro: daemon failure-mode classification', () => { await new Promise((resolve) => wedged.close(() => resolve())) } }, 15000) - - // No daemon at all (or token missing) -> unreachable. - it('UNREACHABLE: no daemon listening classifies as unreachable', async () => { - await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('unreachable') - }) }) diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index b50ef12baac..0c10f1493a5 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -206,7 +206,8 @@ export function createPtyShellLaunchPlan( hasStartupCommand: Boolean(opts.command), waitsForShellReady, emitsStartupIdentity: waitsForShellReady - }) + }), + { hasStartupCommand: Boolean(opts.command) } ) Object.assign(env, shellLaunch.env) shellArgs = diff --git a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts index f58536f36b8..91658df7d8a 100644 --- a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts +++ b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts @@ -17,8 +17,8 @@ import { iterateTerminalHistorySeedChunks } from './terminal-history-seed-chunks // never armed mouse reporting, yet its snapshot re-arms it, so pointer motion // echoes literal SGR reports (^[[<35;col;rowM) into the prompt. // -// This drives the REAL Session / HeadlessEmulator / TerminalMouseModeMirror / -// buildRehydrateSequences / HistoryManager / HistoryReader, with a fake +// This drives the REAL Session / HeadlessEmulator / buildRehydrateSequences / +// HistoryManager / HistoryReader, with a fake // subprocess standing in for node-pty (a real PTY can't be SIGKILLed // deterministically mid-DECSET in vitest). The kill goes through Session.kill()'s // real teardown; only killWithDescendantSweep is stubbed so the test never diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 298aaa681bf..27863c82ec9 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -43,8 +43,20 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'codex-0157-effort-override-embedded-warning': 4, 'codex-0157-no-daemon-effort-override': 16, 'codex-0157-plain-ready': 18, + // Fresh-home 0.157/0.158 captures: the live pen's true-colour fg/bg leaks onto restored cells. + 'codex-0157-fresh-home-daemon-install': 48, + 'codex-0158-fresh-home-greeting': 9, + 'codex-0158-model-announcement-dialog': 8, + // Codex 0.157/0.158 startup-dialog captures: the same live-pen true-colour leak onto restored cells. + 'codex-0157-update-available-dialog': 26, + 'codex-0158-update-available-dialog': 8, + 'codex-0157-hooks-review-dialog': 24, + 'codex-0158-hooks-review-dialog': 8, + 'codex-0157-model-retired-dialog': 22, + 'codex-0158-model-retired-dialog': 6, // Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures). 'codex-0-157-1-update-dialog': 16, + 'codex-0-157-1-timed-sleep-turn': 6, 'codex-0-158-0-approval': 12, 'codex-0-158-0-timed-turn': 20, 'codex-0-158-0-trustprompt': 36, diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 191b8ee7eaa..65e6815b47d 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -4,6 +4,10 @@ import { SESSION_FORCE_KILL_RETRY_MS } from './session-termination-controller' import { HeadlessEmulator } from './headless-emulator' import type { SessionState, ShellReadyState } from './types' import type { TuiAgent } from '../../shared/tui-agent' +import { + _resetPtyOwnerHostColorsForTest, + setPtyOwnerHostColors +} from '../../shared/pty-owner-color-query-colors' const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) vi.mock('../pty-descendant-termination', () => ({ @@ -300,54 +304,57 @@ describe('Session', () => { expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length) }) - it('contains legacy paired-runtime reply echoes and removes their downstream producer', async () => { + it('answers a late query itself on every backend, so no downstream view is asked', () => { const query = '\x1b]10;?\x07' - const reply = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' - const projectedEcho = reply.replaceAll('\x1b', '^[') - createSession({ ownerBackend: 'posix-pty' }) - session.closeStartupQueryAuthority() - const legacyReplyProducers: string[] = [] - const legacyOnData = vi.fn((data: string) => { - if (data === query) { - legacyReplyProducers.push('remote-visible-renderer') - session.write(reply) + // Orca's default theme: nothing reported colours for this session. + const reply = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' + for (const ownerBackend of ['posix-pty', 'windows-conpty'] as const) { + subprocess = createMockSubprocess() + createSession({ ownerBackend }) + session.closeStartupQueryAuthority() + const downstreamReplies: string[] = [] + const onData = vi.fn((data: string) => { + if (data.includes(query)) { + downstreamReplies.push(data) + session.write(reply) + } + }) + session.attachClient({ onData, onExit: () => {} }) + + subprocess.simulateData(query) + subprocess.simulateData('prompt') + + expect(downstreamReplies, ownerBackend).toEqual([]) + expect(subprocess.written, ownerBackend).toEqual([reply]) + expect(onData.mock.calls, ownerBackend).toEqual([ + ['', query.length, true, query.length], + ['prompt'] + ]) + expect(session.getSnapshot()?.snapshotAnsi, ownerBackend).not.toContain(']10;rgb') + session.dispose() + } + }) + + it('answers from the daemon-wide colours pushed after the session started', () => { + createSession({ + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 } }) - session.attachClient({ onData: legacyOnData, onExit: () => {} }) - subprocess.simulateData(query) + subprocess.simulateData('\x1b]11;?\x07') + try { + setPtyOwnerHostColors({ foreground: '#000000', background: '#123456' }) + subprocess.simulateData('\x1b]11;?\x07') + } finally { + _resetPtyOwnerHostColorsForTest() + } - expect(legacyReplyProducers).toEqual(['remote-visible-renderer']) - // Written in the calling turn — the echo is contained on the output side below, - // not by withholding the write. - expect(subprocess.written).toEqual([reply]) - subprocess.simulateData(projectedEcho) - expect(legacyOnData.mock.calls).toEqual([ - [query], - ['', projectedEcho.length, true, query.length + projectedEcho.length] + expect(subprocess.written).toEqual([ + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + '\x1b]11;rgb:1212/3434/5656\x1b\\' ]) - expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb') - session.dispose() - - subprocess = createMockSubprocess() - createSession({ ownerBackend: 'windows-conpty' }) - session.closeStartupQueryAuthority() - const fixedReplyProducers: string[] = [] - const fixedOnData = vi.fn((data: string) => { - if (data === query) { - fixedReplyProducers.push('remote-visible-renderer') - session.write(reply) - } - }) - session.attachClient({ onData: fixedOnData, onExit: () => {} }) - - subprocess.simulateData(query) - subprocess.simulateData('prompt') - - expect(fixedReplyProducers).toEqual([]) - expect(subprocess.written).toEqual([]) - expect(fixedOnData.mock.calls).toEqual([['', query.length, true, query.length], ['prompt']]) - expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb') }) }) @@ -367,10 +374,9 @@ describe('Session', () => { // structural (terminal-query-authority.md): a delivered chunk is // answered by the consuming view's xterm, a hidden-dropped chunk by // MAIN's runtime model responder. The daemon emulator is neither — it - // stays write-only forever, and these pins are permanent. + // stays write-only forever, and these pins are permanent. OSC 10/11 are + // answered once by the session's source ingress, never by its emulator. it.each([ - ['OSC 10 foreground-color', '\x1b]10;?\x07'], - ['OSC 11 background-color', '\x1b]11;?\x07'], ['OSC 12 cursor-color', '\x1b]12;?\x1b\\'], ['DA1 device-attributes', '\x1b[c'], ['DA2 secondary device-attributes', '\x1b[>c'], diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 5208f9ced6b..a1065ba753c 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -6,7 +6,7 @@ import { getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' -import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { getFishShellReadyInitCommand } from '../shell-templates' import { encodeShellStartupFeatures, @@ -126,7 +126,8 @@ const UNWRAPPED: ShellLaunchConfig = { */ export function getShellLaunchConfig( shellPath: string, - features: readonly ShellStartupFeature[] + features: readonly ShellStartupFeature[], + options: { hasStartupCommand?: boolean } = {} ): ShellLaunchConfig { const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() @@ -177,17 +178,18 @@ export function getShellLaunchConfig( } } - // Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped. The - // selection is baked into the init command, so fish needs no feature env var. - if (shellName === 'fish' && features.includes('ready')) { + // Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped unless a + // startup command (e.g. Orca's Codex launch) needs the codex wrapper. The selection + // is baked into the init command, so fish needs no feature env var. + if (shellName === 'fish' && (features.includes('ready') || options.hasStartupCommand)) { return { args: [ '-l', '-C', - `${getFishShellReadyInitCommand(SHELL_READY_MARKER)}\n${getFishCodexShellLaunchPreflight()}` + `${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight()}` ], env: {}, - supportsReadyMarker: true + supportsReadyMarker: features.includes('ready') } } diff --git a/src/main/daemon/terminal-host-pty-owner-backend.test.ts b/src/main/daemon/terminal-host-pty-owner-backend.test.ts index 3dc14863884..11de6bcdf56 100644 --- a/src/main/daemon/terminal-host-pty-owner-backend.test.ts +++ b/src/main/daemon/terminal-host-pty-owner-backend.test.ts @@ -69,37 +69,32 @@ describe('TerminalHost PTY owner backend', () => { return subprocess } + // Orca's default theme: nothing reported colours for this session. + const query = '\x1b]10;?\x07' + const reply = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' + // ConPTY echoes a reply with its ESC bytes stripped; wsl.exe's echo shape is unverified. + const conptyEcho = reply.replaceAll('\x1b', '') + it('uses the spawned native shell over stale requested WSL metadata', async () => { - const replyProducers: string[] = [] - const onData = vi.fn((data: string) => { - if (data === '\x1b]10;?\x07') { - replyProducers.push('renderer') - host.write('owner-test', '\x1b]10;rgb:ffff/ffff/ffff\x1b\\') - } - }) + const onData = vi.fn() const subprocess = await createSession('powershell.exe', 'Ubuntu', onData) - subprocess.emitData('\x1b]10;?\x07') + subprocess.emitData(query) + subprocess.emitData(conptyEcho) - expect(replyProducers).toEqual([]) - expect(onData).toHaveBeenCalledWith('', '\x1b]10;?\x07'.length, true, '\x1b]10;?\x07'.length) - expect(subprocess.write).not.toHaveBeenCalled() + expect(subprocess.write).toHaveBeenCalledWith(reply) + expect(onData).toHaveBeenCalledWith('', query.length, true, query.length) + expect(onData).not.toHaveBeenCalledWith(conptyEcho) }) - it('keeps replies for an actually spawned WSL shell', async () => { - const reply = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' - const replyProducers: string[] = [] - const onData = vi.fn((data: string) => { - if (data === '\x1b]10;?\x07') { - replyProducers.push('renderer') - host.write('owner-test', reply) - } - }) + it('treats an actually spawned WSL shell as wsl.exe, not ConPTY', async () => { + const onData = vi.fn() const subprocess = await createSession('wsl.exe', undefined, onData) - subprocess.emitData('\x1b]10;?\x07') + subprocess.emitData(query) + subprocess.emitData(conptyEcho) - expect(replyProducers).toEqual(['renderer']) expect(subprocess.write).toHaveBeenCalledWith(reply) + expect(onData).toHaveBeenCalledWith(conptyEcho) }) }) diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts deleted file mode 100644 index 7e2ea703cac..00000000000 --- a/src/main/daemon/terminal-mouse-mode-mirror.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ownRetainedString } from '../../shared/own-retained-string' -import type { TerminalModes } from './types' - -type MouseTrackingMode = NonNullable - -// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. -// Keep parser state far beyond normal mode lists while still bounding memory. -const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 - -/** - * Mirrors DECSET mouse-protocol/encoding state from the raw byte stream. - * xterm's public modes API does not expose which mouse protocol is active, - * so snapshots track it independently of the headless terminal; callers - * must feed `scan()` the same bytes the terminal parsed, in order. - */ -export class TerminalMouseModeMirror { - private scanTail = '' - private trackingModeState: MouseTrackingMode = 'none' - private sgrMouseModeState = false - private sgrMousePixelsModeState = false - - get mouseTrackingMode(): MouseTrackingMode { - return this.trackingModeState - } - - get sgrMouseMode(): boolean { - return this.sgrMouseModeState - } - - get sgrMousePixelsMode(): boolean { - return this.sgrMousePixelsModeState - } - - scan(data: string): void { - // Why the pre-filter: this runs on the daemon's per-chunk hot path for - // every session; a flood chunk with no private-mode/reset introducer - // must not pay the regex pass (measured share of a 2.2x ingest - // regression — findings log 2026-07-03). Split sequences stay correct: - // an introducer split across chunks either left a non-empty scanTail - // (previous partial) or ends this chunk, which extractScanTail retains. - if ( - this.scanTail.length === 0 && - !data.includes('\x1b[?') && - !data.includes('\x1bc') && - !data.includes('\x9b') - ) { - this.scanTail = this.extractScanTail(data) - return - } - const input = this.scanTail.length === 0 ? data : this.scanTail + data - this.scanTail = this.extractScanTail(input) - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g - let match: RegExpExecArray | null - while ((match = privateModeRe.exec(input)) !== null) { - if (match[0] === '\x1bc') { - this.trackingModeState = 'none' - this.sgrMouseModeState = false - this.sgrMousePixelsModeState = false - continue - } - const params = match[1] ?? match[3] - const enabled = (match[2] ?? match[4]) === 'h' - for (const rawParam of params.split(';')) { - if (rawParam === '') { - continue - } - const param = Number(rawParam) - if (!Number.isInteger(param)) { - continue - } - if (param === 9) { - this.trackingModeState = enabled ? 'x10' : 'none' - } - if (param === 1000) { - this.trackingModeState = enabled ? 'vt200' : 'none' - } - if (param === 1002) { - this.trackingModeState = enabled ? 'drag' : 'none' - } - if (param === 1003) { - this.trackingModeState = enabled ? 'any' : 'none' - } - if (param === 1006) { - this.sgrMouseModeState = enabled - this.sgrMousePixelsModeState = false - } - if (param === 1016) { - this.sgrMouseModeState = false - this.sgrMousePixelsModeState = enabled - } - } - } - } - - private extractScanTail(input: string): string { - const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) - if (start === -1) { - return '' - } - const tail = input.slice(start) - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { - return '' - } - if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { - return tail - } - if (tail.startsWith('\x1b[?')) { - return this.isIncompleteParams(tail.slice(3)) ? ownRetainedString(tail) : '' - } - if (tail.startsWith('\x9b?')) { - return this.isIncompleteParams(tail.slice(2)) ? ownRetainedString(tail) : '' - } - return '' - } - - private isIncompleteParams(params: string): boolean { - return /^[0-9;]*$/.test(params) - } -} diff --git a/src/main/daemon/terminal-mouse-tail-retention.test.ts b/src/main/daemon/terminal-mouse-tail-retention.test.ts deleted file mode 100644 index 7a266b4b23d..00000000000 --- a/src/main/daemon/terminal-mouse-tail-retention.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' - -function heapAfterGc(): number { - if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') { - throw new Error('The test runner must enable --expose-gc') - } - // Isolate mirror ownership from V8's process-wide last successful regexp input. - void /reset/.test('reset') - globalThis.gc() - globalThis.gc() - return process.memoryUsage().heapUsed -} - -describe('mouse mode scan tail retention', () => { - it.each(['\x1b[', '\x9b'])( - 'retains a split %j mode sequence without retaining consumed output', - (introducer) => { - const before = heapAfterGc() - const mirrors = Array.from({ length: 8 }, (_value, index) => { - const mirror = new TerminalMouseModeMirror() - mirror.scan(`${index}:${'x'.repeat(4 * 1024 * 1024)}${introducer}?1049;2004;1000;`) - return mirror - }) - - expect(heapAfterGc() - before).toBeLessThan(2 * 1024 * 1024) - for (const mirror of mirrors) { - expect(mirror.mouseTrackingMode).toBe('none') - mirror.scan('1006h') - expect(mirror.mouseTrackingMode).toBe('vt200') - expect(mirror.sgrMouseMode).toBe(true) - mirror.scan('\x1b[?1016h') - expect(mirror.sgrMouseMode).toBe(false) - expect(mirror.sgrMousePixelsMode).toBe(true) - mirror.scan('\x1bc') - expect(mirror.mouseTrackingMode).toBe('none') - expect(mirror.sgrMousePixelsMode).toBe(false) - } - } - ) -}) diff --git a/src/main/daemon/terminal-snapshot-mouse-encoding.test.ts b/src/main/daemon/terminal-snapshot-mouse-encoding.test.ts new file mode 100644 index 00000000000..0822aada4e1 --- /dev/null +++ b/src/main/daemon/terminal-snapshot-mouse-encoding.test.ts @@ -0,0 +1,162 @@ +// #23818: a restored pane must keep the mouse encoding (DECSET 1006/1016) with +// the tracking mode, or it reports wheel events as legacy `ESC [ M` bytes that a +// ConPTY host delivers to the app as typed text. +import './xterm-env-polyfill' +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { HeadlessEmulator } from './headless-emulator' +import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor' +import { readTerminalMouseEncoding } from '../../shared/terminal-mouse-encoding' + +const CODEX_FULLSCREEN_MODES = '\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h' +const X10_WHEEL_UP_AT_10_5 = `\x1b[M${String.fromCharCode(96, 42, 37)}` +const SGR_WHEEL_UP_AT_10_5 = '\x1b[<64;10;5M' + +function createTerminal(): { terminal: Terminal; addon: SerializeAddon } { + const terminal = new Terminal({ cols: 40, rows: 10, scrollback: 100, allowProposedApi: true }) + const addon = new SerializeAddon() + terminal.loadAddon(addon) + return { terminal, addon } +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, () => resolve())) +} + +async function replay(data: string): Promise { + const { terminal } = createTerminal() + await write(terminal, data) + return terminal +} + +type XtermCoreMouseEvent = { + col: number + row: number + x: number + y: number + button: number + action: number +} + +/** xterm's private mouse state service: the encoder desktop viewers send reports through. */ +type XtermMouseStateService = { + areMouseEventsActive: boolean + encodeMouseEvent: (event: XtermCoreMouseEvent) => string +} + +function isXtermMouseStateService(value: unknown): value is XtermMouseStateService { + return ( + typeof value === 'object' && + value !== null && + 'areMouseEventsActive' in value && + typeof value.areMouseEventsActive === 'boolean' && + 'encodeMouseEvent' in value && + typeof value.encodeMouseEvent === 'function' + ) +} + +/** The report xterm's own encoder sends for a wheel-up, or null when tracking is off. */ +function wheelUpReport(terminal: Terminal): string | null { + const core = '_core' in terminal ? terminal._core : undefined + const service = + typeof core === 'object' && core !== null && 'mouseStateService' in core + ? core.mouseStateService + : undefined + if (!isXtermMouseStateService(service)) { + throw new Error('xterm mouse state service is unavailable') + } + if (!service.areMouseEventsActive) { + return null + } + return service.encodeMouseEvent({ col: 10, row: 5, x: 0, y: 0, button: 4, action: 0 }) +} + +describe('snapshot mouse encoding', () => { + it('the bare addon drops SGR encoding, so its restore reports X10 (the #23818 shape)', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, `shell$ ${CODEX_FULLSCREEN_MODES}codex`) + + const restored = await replay(addon.serialize()) + + expect(restored.modes.mouseTrackingMode).toBe('any') + expect(wheelUpReport(restored)).toBe(X10_WHEEL_UP_AT_10_5) + }) + + it('round-trips SGR encoding with the tracking mode, so the restore reports SGR', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, `shell$ ${CODEX_FULLSCREEN_MODES}codex`) + + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.modes.mouseTrackingMode).toBe('any') + expect(readTerminalMouseEncoding(restored)).toBe('sgr') + expect(wheelUpReport(restored)).toBe(SGR_WHEEL_UP_AT_10_5) + }) + + it('round-trips SGR-pixels encoding', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, 'x\x1b[?1002h\x1b[?1016h') + + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + + expect(restored.modes.mouseTrackingMode).toBe('drag') + expect(readTerminalMouseEncoding(restored)).toBe('sgr-pixels') + }) + + it('restores no mouse reports when tracking is off, even with SGR encoding armed', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, 'x\x1b[?1003h\x1b[?1006h\x1b[?1003l') + + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + + expect(restored.modes.mouseTrackingMode).toBe('none') + expect(wheelUpReport(restored)).toBeNull() + }) + + it('adds nothing for the default encoding or when modes are excluded', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, 'x\x1b[?1000h') + const legacy = serializeWithAbsoluteCursor(addon, terminal) + expect(legacy).toContain('\x1b[?1000h') + expect(legacy).not.toContain('\x1b[?1006h') + expect(legacy).not.toContain('\x1b[?1016h') + + await write(terminal, '\x1b[?1006h') + const excluded = serializeWithAbsoluteCursor(addon, terminal, { excludeModes: true }) + expect(excluded).not.toContain('\x1b[?1006h') + }) + + it('carries the encoding from a renderer snapshot through a headless seed to its snapshot', async () => { + // Why: the runtime seeds its headless model from the desktop pane's snapshot, and + // mobile keeps only what follows the last `?1049h` of that model's snapshot. + const { terminal, addon } = createTerminal() + await write(terminal, `shell$ ${CODEX_FULLSCREEN_MODES}codex`) + const emulator = new HeadlessEmulator({ cols: 40, rows: 10 }) + await emulator.write(serializeWithAbsoluteCursor(addon, terminal)) + + const snapshot = emulator.getSnapshot() + const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi + const mobileReplay = data.slice(data.lastIndexOf('\x1b[?1049h')) + + expect(snapshot.modes.mouseTrackingMode).toBe('any') + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(mobileReplay).toContain('\x1b[?1003h') + expect(mobileReplay).toContain('\x1b[?1006h') + expect(wheelUpReport(await replay(data))).toBe(SGR_WHEEL_UP_AT_10_5) + emulator.dispose() + }) + + it('a snapshot from an older host without the encoding restores as before', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[?1003hcodex') + + const snapshot = emulator.getSnapshot() + + expect(snapshot.modes.mouseTrackingMode).toBe('any') + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h') + emulator.dispose() + }) +}) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2ef4dfeb117..adf1e8a1781 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -22,6 +22,7 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' +import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol' export type { TerminalModes } from './terminal-modes' import type { TerminalSnapshot } from './terminal-snapshot' export type { TerminalSnapshot } from './terminal-snapshot' @@ -99,12 +100,6 @@ export type CreateOrAttachRequest = { } } -export type CloseStartupQueryAuthorityRequest = { - id: string - type: 'closeStartupQueryAuthority' - payload: { sessionId: string } -} - export type CancelCreateOrAttachRequest = { id: string type: 'cancelCreateOrAttach' @@ -330,7 +325,8 @@ export type DaemonRequest = | GetSnapshotRequest | GetSizeRequest | TakePendingOutputRequest - | CloseStartupQueryAuthorityRequest + | PtyOwnerQueryProtocol.CloseStartupQueryAuthorityRequest + | PtyOwnerQueryProtocol.SetColorQueryReplyColorsRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── diff --git a/src/main/execution-host-workspace-trust.test.ts b/src/main/execution-host-workspace-trust.test.ts new file mode 100644 index 00000000000..c544f351ce8 --- /dev/null +++ b/src/main/execution-host-workspace-trust.test.ts @@ -0,0 +1,200 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Os from 'node:os' +import type { AgentTrustPreset } from './agent-trust-presets' +import { linkGitWorktree, workspaceTrustWritten } from './workspace-trust-test-fixtures' + +const state = vi.hoisted(() => ({ home: '' })) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => state.home } +}) + +import { + AGENT_TRUST_INHERITS_FROM_A_HOME, + applyWorkspaceTrustOnThisHost, + type WorkspaceTrustHost +} from './execution-host-workspace-trust' + +const PRESETS: readonly AgentTrustPreset[] = [ + 'claude', + 'codex', + 'cursor', + 'copilot', + 'qoder', + 'antigravity' +] +// Why these three: each accepts trust from any ancestor folder, so trust on a home covers it all. +const INHERITING_PRESETS: readonly AgentTrustPreset[] = ['claude', 'copilot', 'qoder'] +const EXACT_OR_SELF_LIMITING_PRESETS: readonly AgentTrustPreset[] = [ + 'codex', + 'cursor', + 'antigravity' +] + +let root: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-host-trust-'))) + state.home = join(root, 'home', 'me') + mkdirSync(state.home, { recursive: true }) + writeFileSync(join(state.home, '.claude.json'), '{}') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function thisHost(overrides: Partial = {}): () => WorkspaceTrustHost { + return () => ({ + homes: [state.home], + claudeConfig: () => ({ configFile: join(state.home, '.claude.json'), keyStyle: 'posix' }), + codexConfigFiles: () => [join(state.home, '.codex', 'config.toml')], + deadlineMs: 1_500, + ...overrides + }) +} + +function trustWritten(preset: AgentTrustPreset): boolean { + return workspaceTrustWritten(state.home, preset) +} + +describe('applyWorkspaceTrustOnThisHost', () => { + it.each(PRESETS)('writes %s trust for an ordinary project folder', async (preset) => { + const workspace = join(root, 'projects', 'app') + mkdirSync(workspace, { recursive: true }) + await applyWorkspaceTrustOnThisHost(preset, workspace, thisHost()) + expect(trustWritten(preset)).toBe(true) + }) + + const tooBroad: [string, () => { workspace: string; homes?: string[] }][] = [ + ['the home', () => ({ workspace: state.home })], + ['a folder containing the home', () => ({ workspace: join(root, 'home') })], + ['a filesystem root', () => ({ workspace: '/' })], + [ + 'a symlink to the home', + () => { + symlinkSync(state.home, join(root, 'home-link'), 'junction') + return { workspace: join(root, 'home-link') } + } + ], + [ + // Why a missing segment: realpath fails there, and join() would collapse the `..` itself. + 'a missing path that resolves to the home through ..', + () => ({ workspace: [state.home, 'missing', '..'].join(sep) }) + ], + [ + 'the home, when the host names it through a symlink', + () => { + symlinkSync(state.home, join(root, 'home-link'), 'junction') + return { workspace: state.home, homes: [join(root, 'home-link')] } + } + ] + ] + describe.each(tooBroad)('for %s', (_label, arrange) => { + it.each(INHERITING_PRESETS)('writes no %s trust', async (preset) => { + const { workspace, homes } = arrange() + await applyWorkspaceTrustOnThisHost( + preset, + workspace, + thisHost(homes ? { homes } : undefined) + ) + expect(trustWritten(preset)).toBe(false) + }) + }) + + it('guards exactly the agents that inherit trust from a home', () => { + expect(PRESETS.filter((preset) => AGENT_TRUST_INHERITS_FROM_A_HOME[preset])).toEqual( + INHERITING_PRESETS + ) + }) + + it.each(EXACT_OR_SELF_LIMITING_PRESETS)( + 'trusts a home folder workspace for %s, whose trust there covers only the home', + async (preset) => { + await applyWorkspaceTrustOnThisHost(preset, state.home, thisHost()) + expect(trustWritten(preset)).toBe(true) + } + ) + + it('trusts a home folder workspace for Codex under the key Codex looks up', async () => { + await applyWorkspaceTrustOnThisHost('codex', state.home, thisHost()) + expect(readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8')).toContain( + `[projects."${state.home}"]` + ) + }) + + it.each(PRESETS)( + 'trusts a worktree whose main checkout is the home for %s, which stores the worktree path', + async (preset) => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(state.home, worktree) + await applyWorkspaceTrustOnThisHost(preset, worktree, thisHost()) + expect(trustWritten(preset)).toBe(true) + } + ) + + it("stores a Codex worktree's own path, not its main checkout", async () => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(state.home, worktree) + await applyWorkspaceTrustOnThisHost('codex', worktree, thisHost()) + const written = readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain(`[projects."${worktree}"]`) + expect(written).not.toContain(`[projects."${state.home}"]`) + }) + + it('trusts the exact subfolder Codex starts in, inside a folder that is not a repo', async () => { + const subfolder = join(root, 'notes', 'sub') + mkdirSync(subfolder, { recursive: true }) + await applyWorkspaceTrustOnThisHost('codex', subfolder, thisHost()) + expect(readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8')).toContain( + `[projects."${subfolder}"]` + ) + }) + + it.each(INHERITING_PRESETS)('writes no %s trust when the host knows no home', async (preset) => { + const workspace = join(root, 'projects', 'app') + mkdirSync(workspace, { recursive: true }) + await applyWorkspaceTrustOnThisHost( + preset, + workspace, + thisHost({ homes: [null, '', undefined] }) + ) + expect(trustWritten(preset)).toBe(false) + }) + + it('contains a host description or writer that throws, so the launch proceeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const workspace = join(root, 'projects', 'app') + mkdirSync(workspace, { recursive: true }) + await expect( + applyWorkspaceTrustOnThisHost('claude', workspace, () => { + throw new Error('homedir unavailable') + }) + ).resolves.toBeUndefined() + await expect( + applyWorkspaceTrustOnThisHost( + 'codex', + workspace, + thisHost({ + codexConfigFiles: () => { + throw new Error('userData unavailable') + } + }) + ) + ).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) +}) diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts new file mode 100644 index 00000000000..42f48e918ce --- /dev/null +++ b/src/main/execution-host-workspace-trust.ts @@ -0,0 +1,135 @@ +import { realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { markQoderWorkspaceTrusted } from './qoder/workspace-trust' +import { + type AgentTrustPreset, + markAntigravityWorkspaceTrusted, + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted +} from './agent-trust-presets' +import { awaitAgentTrustWriteWithinDeadline } from './agent-trust-write-deadline' +import { + type ClaudeTrustConfigTarget, + grantClaudeWorkspaceTrust +} from './claude/claude-folder-trust-file' +import { isTooBroadToPreTrust } from '../shared/home-or-filesystem-root' + +/** + * What the host that runs the agent knows about where that agent reads trust. Relay-safe: + * main describes this machine or a WSL guest, the SSH relay describes its own host. + */ +export type WorkspaceTrustHost = { + /** Homes the agent may read trust under; with none known, an agent that inherits trust writes nothing. */ + homes: readonly (string | null | undefined)[] + /** The config Claude reads on this host, or null when this host cannot tell. */ + claudeConfig: () => ClaudeTrustConfigTarget | null + /** Every config.toml the launched Codex may read, in the hook installer's lock order. */ + codexConfigFiles: () => readonly string[] + deadlineMs: number +} + +/** + * Whether trust on a home or a disk root would also trust the folders below it, per each agent's + * own lookup; such trust is never pre-written. Claude walks up parent folders (to the repo root, + * else the disk root); Copilot and Qoder accept any trusted ancestor. Codex matches its start + * folder or that folder's repo root, Antigravity the exact folder, and Cursor never inherits from + * a home, a folder above one or a shallow path. + */ +export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record = { + claude: true, + codex: false, + cursor: false, + copilot: true, + qoder: true, + antigravity: false +} + +/** + * Agents whose lookup keys on the folder they start in, so Orca trusts that folder, not the + * workspace root, even outside any workspace. Codex checks its start folder, then that folder's + * repo root; a non-git workspace root above the start folder is neither. + */ +export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record = { + claude: false, + codex: true, + cursor: false, + copilot: false, + qoder: false, + antigravity: false +} + +// Why resolve() too: Claude stores it, and it collapses `..` even where realpath fails. +function withResolvedForm(path: string): string[] { + try { + return [path, resolve(path), realpathSync.native(path)] + } catch { + return [path, resolve(path)] + } +} + +/** + * Whether trust stored for `storedPath` would cover a home: it is a root, a home or a folder + * above one. Both sides are compared given and resolved, since the writers store the realpath. + */ +function wouldTrustAHome(storedPath: string, homes: readonly string[]): boolean { + const homeForms = homes.flatMap(withResolvedForm) + return withResolvedForm(storedPath).some((form) => isTooBroadToPreTrust(form, homeForms)) +} + +async function writePreset( + preset: AgentTrustPreset, + storedPath: string, + host: WorkspaceTrustHost +): Promise { + switch (preset) { + case 'claude': { + const target = host.claudeConfig() + if (target) { + await grantClaudeWorkspaceTrust(target, storedPath) + } + return + } + case 'codex': + return markCodexProjectTrusted(storedPath, host.codexConfigFiles()) + case 'cursor': + return markCursorWorkspaceTrusted(storedPath) + case 'copilot': + return markCopilotFolderTrusted(storedPath) + case 'qoder': + return markQoderWorkspaceTrusted(storedPath) + case 'antigravity': + return markAntigravityWorkspaceTrusted(storedPath) + } +} + +/** + * The one place a preset's trust is written, on the host that runs the agent. For an agent that + * inherits trust from a home, refuses when the path the writer would store covers a home. Never + * throws or waits past the host's deadline: any failure or miss means the agent asks. + */ +export async function applyWorkspaceTrustOnThisHost( + preset: AgentTrustPreset, + workspacePath: string, + describeHost: () => WorkspaceTrustHost +): Promise { + try { + const host = describeHost() + if (AGENT_TRUST_INHERITS_FROM_A_HOME[preset]) { + const homes = host.homes.filter((home): home is string => Boolean(home)) + if (homes.length === 0 || wouldTrustAHome(workspacePath, homes)) { + return + } + } + await awaitAgentTrustWriteWithinDeadline(writePreset(preset, workspacePath, host), { + preset, + workspacePath, + deadlineMs: host.deadlineMs + }) + } catch (error) { + console.warn( + `[agent-trust] ${preset} trust for ${workspacePath} failed; the agent will ask`, + error + ) + } +} diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts index f343246571d..b94d2f59509 100644 --- a/src/main/git/command-runner/exec-file-capture.ts +++ b/src/main/git/command-runner/exec-file-capture.ts @@ -27,10 +27,8 @@ export async function execFileCaptureToTermination( options: ExecFileCaptureOptions, termination?: WslProcessGroupTermination ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { - // Why measured here: runProcess spawns inside its promise executor, which runs - // synchronously, so this brackets exactly the main-thread block execFileCapture - // reports for its own spawns. - const spawnStartedAt = performance.now() + // Spawn cost is reported by spawnProcess's observer, which runProcess goes + // through; recording it again here would double-count every capture. const pending = runProcess({ program: command, args, @@ -43,7 +41,6 @@ export async function execFileCaptureToTermination( onChildTerminated: options.onChildTerminated, ...(options.stdin === undefined ? {} : { input: options.stdin }) }) - recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) const result = await pending const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr diff --git a/src/main/git/command-runner/git-command-timeout.test.ts b/src/main/git/command-runner/git-command-timeout.test.ts index 1768198a81a..30b5f93d64a 100644 --- a/src/main/git/command-runner/git-command-timeout.test.ts +++ b/src/main/git/command-runner/git-command-timeout.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - GIT_READ_TIMEOUT_MS, - GitCommandTimeoutError, - gitCommandTimeoutMs -} from './git-command-timeout' +import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout' describe('gitCommandTimeoutMs', () => { - it('pins the production read deadline', () => { - expect(GIT_READ_TIMEOUT_MS).toBe(120_000) - }) - it.each([ [['status', '--porcelain=v2'], 120_000], [['show', 'HEAD:file'], 120_000], diff --git a/src/main/git/command-runner/git-subprocess-admission.test.ts b/src/main/git/command-runner/git-subprocess-admission.test.ts index 0e5bfa808ee..1ad55a05135 100644 --- a/src/main/git/command-runner/git-subprocess-admission.test.ts +++ b/src/main/git/command-runner/git-subprocess-admission.test.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { GENERAL_CAP, GitAdmissionScheduler, - MAX_GIT_CHILDREN, - NETWORK_CAP, _gitAdmissionSnapshotForTests, _resetGitAdmissionForTests, acquireGitAdmission @@ -33,11 +31,9 @@ afterEach(() => { }) describe('GitAdmissionScheduler', () => { - it('pins the global base budgets and absolute maximum', () => { + it('pins the global base budget bounds', () => { expect(GENERAL_CAP).toBeGreaterThanOrEqual(2) expect(GENERAL_CAP).toBeLessThanOrEqual(4) - expect(NETWORK_CAP).toBe(3) - expect(MAX_GIT_CHILDREN).toBe(10) }) it('keeps base and headroom counters separate and grants interactive all-headroom', async () => { diff --git a/src/main/git/remote-url-probe.test.ts b/src/main/git/remote-url-probe.test.ts index f9e0348c3ac..0df6ffd6ca8 100644 --- a/src/main/git/remote-url-probe.test.ts +++ b/src/main/git/remote-url-probe.test.ts @@ -44,7 +44,6 @@ describe('remote URL probe', () => { cwd: '/repo', timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) - expect(REMOTE_URL_PROBE_TIMEOUT_MS).toBe(30_000) }) it('bounds the SSH remote read with the same deadline as the local one', async () => { diff --git a/src/main/git/repo-default-base-ref.ts b/src/main/git/repo-default-base-ref.ts index c43cd14c72a..8fb3acd764e 100644 --- a/src/main/git/repo-default-base-ref.ts +++ b/src/main/git/repo-default-base-ref.ts @@ -1,5 +1,5 @@ import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params' -import { gitExecFileAsync, gitExecFileSync } from './runner' +import { gitExecFileAsync } from './runner' export type LocalGitExecOptions = { wslDistro?: string @@ -43,44 +43,10 @@ async function resolveDefaultBaseRefFromProbes( return null } -function hasGitRef(path: string, ref: string): boolean { - try { - gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path }) - return true - } catch { - return false - } -} - function gitRefToDefaultBaseRef(ref: string): string { return ref.replace(/^refs\/remotes\//, '') } -function getVerifiedOriginHeadBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null - } catch { - return null - } -} - -/** Resolve the default base ref without inventing a fallback branch. */ -export function getDefaultBaseRef(path: string): string | null { - const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) - if (originHeadBaseRef) { - return originHeadBaseRef - } - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (hasGitRef(path, ref)) { - return returnAs - } - } - return null -} - export async function getBaseRefDefault( path: string, options: LocalGitExecOptions = {} diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index 83e3a4c6796..f9105b7e26f 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -78,14 +78,19 @@ export function getGitRepoRoot(path: string): string { if (!existsSync(path) || !statSync(path).isDirectory()) { return path } - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { - cwd: path - }).trim() - return normalizeGitRepoRootForInputPath(path, root) + // One spawn, not two: each sync git call blocks main for up to its whole 15s + // timeout, so the spawn count is the cost. Safe to combine only here — a bare + // repo makes the combined form exit non-zero, and both that throw and the + // plain `false` land on the same marker-scan fallback below. `probeGitRepo` + // must NOT combine: it has to read `false` cleanly to go on and detect bare. + const [insideWorkTree, toplevel] = gitExecFileSync( + ['rev-parse', '--is-inside-work-tree', '--show-toplevel'], + { cwd: path } + ) + .split('\n') + .map((line) => line.trim()) + if (insideWorkTree === 'true' && toplevel) { + return normalizeGitRepoRootForInputPath(path, toplevel) } } catch { // Fall through to preserving the original path. diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index f08ce25eca3..4c75886a922 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { buildSearchBaseRefsArgv, - getDefaultBaseRef, + getBaseRefDefault, getBranchConflictKind, getRemoteCount, parseAndFilterSearchRefDetails, @@ -537,7 +537,7 @@ describe('searchBaseRefs (widened glob)', () => { }) }) -describe('getDefaultBaseRef (regression — unchanged behavior)', () => { +describe('getBaseRefDefault (regression — unchanged behavior)', async () => { let tmpDir: string beforeEach(() => { @@ -549,52 +549,52 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - it('returns origin/main when both origin/main and upstream/main exist (origin wins)', () => { + it('returns origin/main when both origin/main and upstream/main exist (origin wins)', async () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'origin/main', sha) createRemoteRef(tmpDir, 'upstream/main', sha) - const result = getDefaultBaseRef(tmpDir) + const result = await getBaseRefDefault(tmpDir) expect(result).toBe('origin/main') }) - it('returns the target of origin/HEAD when set', () => { + it('returns the target of origin/HEAD when set', async () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'origin/main', sha) git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']) - const result = getDefaultBaseRef(tmpDir) + const result = await getBaseRefDefault(tmpDir) expect(result).toBe('origin/main') }) - it('falls through from a stale origin/HEAD target to an existing primary ref', () => { + it('falls through from a stale origin/HEAD target to an existing primary ref', async () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'origin/main', sha) git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master']) - const result = getDefaultBaseRef(tmpDir) + const result = await getBaseRefDefault(tmpDir) expect(result).toBe('origin/main') }) - it('falls through from a stale origin/HEAD primary target to another existing default ref', () => { + it('falls through from a stale origin/HEAD primary target to another existing default ref', async () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'origin/master', sha) git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']) - const result = getDefaultBaseRef(tmpDir) + const result = await getBaseRefDefault(tmpDir) expect(result).toBe('origin/master') }) - it('does NOT fall through to upstream/main when origin/* is absent', () => { + it('does NOT fall through to upstream/main when origin/* is absent', async () => { // Why: default probe order is origin-only by design; upstream-aware defaulting is deferred. const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) - const result = getDefaultBaseRef(tmpDir) + const result = await getBaseRefDefault(tmpDir) // initRepo creates a local `main`, so with no origin/* we expect it — not `upstream/main`. expect(result).toBe('main') @@ -602,7 +602,7 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { }) }) -describe('resolveDefaultBaseRefViaExec', () => { +describe('resolveDefaultBaseRefViaExec', async () => { it('falls through from a stale origin/HEAD target to the probe list', async () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { @@ -649,7 +649,7 @@ describe('resolveDefaultBaseRefViaExec', () => { }) }) -describe('getRemoteCount', () => { +describe('getRemoteCount', async () => { let tmpDir: string beforeEach(() => { diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index fcf5167f907..decbfa6182c 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -3,12 +3,11 @@ import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-outp import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' import { DEFAULT_BASE_REF_PROBE_TIMEOUT_MS, - getDefaultBaseRef, getDefaultBaseRefAsync, gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' -import { gitExecFileAsync, gitExecFileSync } from './runner' +import { gitExecFileAsync } from './runner' export { isGitRepo, @@ -18,7 +17,6 @@ export { } from './repo-detection' export { DEFAULT_BASE_REF_PROBES, - getDefaultBaseRef, getBaseRefDefault, resolveDefaultBaseRefViaExec, resolveDefaultBaseRefWithLocalGit @@ -43,9 +41,10 @@ export function getRepoName(path: string): string { } /** Get the remote origin URL, or null if not set. */ -export function getRemoteUrl(path: string): string | null { +export async function getRemoteUrl(path: string): Promise { try { - return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim() + const { stdout } = await gitExecFileAsync(['remote', 'get-url', 'origin'], { cwd: path }) + return stdout.trim() } catch { return null } @@ -168,16 +167,16 @@ export async function getDefaultRemote( } /** Build a hosted file URL when the origin belongs to a supported provider. */ -export function getRemoteFileUrl( +export async function getRemoteFileUrl( repoPath: string, relativePath: string, line: number -): string | null { - const remoteUrl = getRemoteUrl(repoPath) +): Promise { + const remoteUrl = await getRemoteUrl(repoPath) if (!remoteUrl) { return null } - const defaultBaseRef = getDefaultBaseRef(repoPath) + const defaultBaseRef = await getDefaultBaseRefAsync(repoPath) if (!defaultBaseRef) { return null } @@ -190,7 +189,7 @@ export function getRemoteFileUrl( } /** Build a hosted commit URL when the origin belongs to a supported provider. */ -export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { - const remoteUrl = getRemoteUrl(repoPath) +export async function getRemoteCommitUrl(repoPath: string, sha: string): Promise { + const remoteUrl = await getRemoteUrl(repoPath) return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null } diff --git a/src/main/git/status-read-coalescing.test.ts b/src/main/git/status-read-coalescing.test.ts index e0170f8d6a5..e7d755e7cfa 100644 --- a/src/main/git/status-read-coalescing.test.ts +++ b/src/main/git/status-read-coalescing.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' -import type * as NodeFs from 'node:fs' -import path from 'node:path' import { createBoundedFileReaderModuleMock, createFsPromisesModuleMock, @@ -92,62 +90,6 @@ describe('getStatus', () => { expect(addOptions?.preferWslDirectGit).toBeUndefined() }) - it('benchmarks concurrent status burst subprocess pressure', async () => { - const benchPath = process.env.ORCA_GIT_STATUS_COALESCING_BENCH_JSON - if (!benchPath) { - return - } - - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ stdout: '' }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const runBurst = async (withSignals: boolean): Promise => { - gitExecFileAsyncMock.mockClear() - await Promise.all( - Array.from({ length: 10 }, () => - getStatus('/repo', withSignals ? { signal: new AbortController().signal } : {}) - ) - ) - return gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('status') - ).length - } - - const startedAt = performance.now() - const unsignalledStatusCommandCalls = await runBurst(false) - const signalledStatusCommandCalls = await runBurst(true) - const durationMs = performance.now() - startedAt - const { mkdirSync, writeFileSync } = await vi.importActual('fs') - mkdirSync(path.dirname(benchPath), { recursive: true }) - writeFileSync( - benchPath, - JSON.stringify({ - scenario: 'git-status-concurrent-burst', - concurrentCalls: 10, - unsignalledStatusCommandCalls, - signalledStatusCommandCalls, - statusArgs: [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ], - durationMs - }) - ) - }) - it('coalesces identical in-flight status reads without caching after settle', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index b7644ad5543..1c4c45e0911 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -1,6 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFs from 'node:fs' -import path from 'node:path' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() @@ -25,54 +23,7 @@ describe('getUpstreamStatus', () => { invalidateGitUpstreamStatusReads() }) - it('benchmarks concurrent upstream Git command pressure', async () => { - const benchPath = process.env.ORCA_GIT_UPSTREAM_COALESCING_BENCH_JSON - if (!benchPath) { - return - } - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'main\n' }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: 'origin/main\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '2\t3\n' }) - } - if (args[0] === 'log') { - return Promise.resolve({ stdout: '+ abc123 remote work\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - await Promise.all(Array.from({ length: 10 }, () => getUpstreamStatus('/repo'))) - - const commands = gitExecFileAsyncMock.mock.calls.map(([args, options]) => ({ args, options })) - const commandCounts = Object.fromEntries( - ['symbolic-ref', 'rev-parse', 'rev-list', 'log'].map((command) => [ - command, - commands.filter(({ args }) => args[0] === command).length - ]) - ) - const { mkdirSync, writeFileSync } = await vi.importActual('node:fs') - mkdirSync(path.dirname(benchPath), { recursive: true }) - writeFileSync( - benchPath, - JSON.stringify({ - scenario: 'local-git-upstream-concurrent-burst', - concurrentCalls: 10, - physicalGitCalls: commands.length, - commandCounts, - commandChain: ['symbolic-ref', 'rev-parse', 'rev-list', 'log'].map((command) => - commands.find(({ args }) => args[0] === command) - ) - }) - ) - }) - - // Why: the benchmark above only runs under an env var, so this is the CI-enforced - // guard that the native/WSL path actually coalesces rather than fanning out. + // Why: the CI-enforced guard that the native/WSL path actually coalesces rather than fanning out. it('shares one physical read across ten identical native callers', async () => { let resolveSymbolicRef = (): void => {} const symbolicRefGate = new Promise((resolve) => { diff --git a/src/main/git/worktree-add-timeout-override.test.ts b/src/main/git/worktree-add-timeout-override.test.ts index 97666c09d4a..50aa4e59db1 100644 --- a/src/main/git/worktree-add-timeout-override.test.ts +++ b/src/main/git/worktree-add-timeout-override.test.ts @@ -26,11 +26,7 @@ vi.mock('../worktree-trash', () => ({ scheduleWorktreeTrashDeletion: vi.fn() })) -import { - resolveWorktreeAddTimeoutMs, - WORKTREE_ADD_TIMEOUT_MAX_MS, - WORKTREE_ADD_TIMEOUT_MS -} from './worktree' +import { resolveWorktreeAddTimeoutMs, WORKTREE_ADD_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() @@ -46,12 +42,6 @@ describe('resolveWorktreeAddTimeoutMs', () => { warnSpy.mockRestore() }) - // Why: pin the literals so a future edit to either bound has to be deliberate. - it('bounds the override to [180s, 30min]', () => { - expect(WORKTREE_ADD_TIMEOUT_MS).toBe(180_000) - expect(WORKTREE_ADD_TIMEOUT_MAX_MS).toBe(1_800_000) - }) - it('falls back to the default when the override is unset, blank, or unparseable', () => { expect(resolveWorktreeAddTimeoutMs({})).toBe(WORKTREE_ADD_TIMEOUT_MS) expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })).toBe( diff --git a/src/main/git/worktree-graph-listing.test.ts b/src/main/git/worktree-graph-listing.test.ts index b626718de69..eecfffe1753 100644 --- a/src/main/git/worktree-graph-listing.test.ts +++ b/src/main/git/worktree-graph-listing.test.ts @@ -195,6 +195,5 @@ branch refs/heads/main cwd: '/repo', timeout: 5_000 }) - expect(WORKTREE_LIST_TIMEOUT_MS).toBe(30_000) }) }) diff --git a/src/main/github/client-rate-limit-block.test.ts b/src/main/github/client-rate-limit-block.test.ts index a8c0a232ffa..969cc16af95 100644 --- a/src/main/github/client-rate-limit-block.test.ts +++ b/src/main/github/client-rate-limit-block.test.ts @@ -88,20 +88,6 @@ describe('getGitHubPRLookupRateLimitBlock', () => { }) }) - it('reports the later reset when graphql outlasts core', async () => { - // Retrying at the earlier reset would fail again on the bucket still blocked. - rateLimitGuardMock.mockImplementation(((bucket: string) => ({ - blocked: true, - remaining: 0, - limit: 5000, - resetAt: bucket === 'graphql' ? 1_800_000_600 : 1_800_000_000 - })) as () => RateLimitGuardResult) - - await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ - resetAt: 1_800_000_600 - }) - }) - it('fails open when the exempt rate-limit probe itself fails', async () => { getRateLimitMock.mockRejectedValue(new Error('probe offline')) diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 76745cf4789..fcaaff529ba 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -27,23 +27,6 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) - it('preserves the coordinator public module API', async () => { - const coordinator = await import('./pr-refresh-coordinator') - - expect(Object.keys(coordinator).sort()).toEqual([ - '_getPRRefreshAliasCountForTests', - '_getPRRefreshErrorBackoffCountForTests', - '_getPRRefreshQueueSizeForTests', - '_getVisiblePRRefreshWindowCountForTests', - 'clearVisiblePRRefreshWindow', - 'enqueuePRRefresh', - 'pruneWorktreePRRefreshAliases', - 'refreshPRNow', - 'reportVisiblePRRefreshCandidates', - 'setPRRefreshOutcomeObserver' - ]) - }) - it('ignores a stale visibility generation before it can replace queued work', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: Date.now() }) diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts deleted file mode 100644 index 187680f64cd..00000000000 --- a/src/main/github/work-item-details-api-parity.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' -import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import type { LocalGitExecOptions } from './gh-utils' -import type { GitHubApiRepository } from './github-api-repository' -import * as workItemDetails from './work-item-details' - -type GetWorkItemDetails = ( - repoPath: string, - number: number, - type?: 'issue' | 'pr', - connectionId?: string | null, - localGitOptions?: LocalGitExecOptions, - preference?: IssueSourcePreference -) => Promise - -type GetPRFileContents = (args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - prRepo?: GitHubApiRepository | null - prNumber: number - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string -}) => Promise - -describe('work-item-details public API parity', () => { - it('retains only the established runtime exports and call signatures', () => { - expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) - expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() - expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() - }) -}) diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts deleted file mode 100644 index 5f7a10fedec..00000000000 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') - -function sourceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('headless automation dispatcher source boundaries', () => { - it('creates new-per-run workspaces from the resolved run target repo', () => { - const createArgsSection = sourceBetween('buildHeadlessAutomationWorktreeCreateArgs({', '})') - - expect(createArgsSection).toContain('repo: target.repo') - expect(createArgsSection).not.toContain('automation.sourceContext') - }) -}) diff --git a/src/main/host/electron-secret-store.test.ts b/src/main/host/electron-secret-store.test.ts index 06ce0b84660..44c9dd8d08c 100644 --- a/src/main/host/electron-secret-store.test.ts +++ b/src/main/host/electron-secret-store.test.ts @@ -18,6 +18,24 @@ describe('ElectronSecretStore', () => { safeStorageMock.getSelectedStorageBackend.mockReturnValue('gnome_libsecret') }) + function withDesktop(desktop: string | undefined, run: () => T): T { + const original = process.env.XDG_CURRENT_DESKTOP + if (desktop === undefined) { + delete process.env.XDG_CURRENT_DESKTOP + } else { + process.env.XDG_CURRENT_DESKTOP = desktop + } + try { + return run() + } finally { + if (original === undefined) { + delete process.env.XDG_CURRENT_DESKTOP + } else { + process.env.XDG_CURRENT_DESKTOP = original + } + } + } + function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -100,6 +118,52 @@ describe('ElectronSecretStore', () => { expect(new ElectronSecretStore().describeProtectionGap()).toMatch(/keyring is unavailable/) }) }) + + // Why these three: on Hyprland/sway/river/niri the desktop is unrecognised, so the + // backend is basic_text AND sealing is unavailable — and the old text told those users + // to install a keyring that gnome-keyring was already serving the whole time. + it('does not blame a missing keyring when the desktop was simply not recognised', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withPlatform('linux', () => { + const gap = new ElectronSecretStore().describeProtectionGap() + expect(gap).toMatch(/could not tell which keyring service/) + expect(gap).toMatch(/already running/) + expect(gap).not.toMatch(/Install and unlock/) + }) + }) + + it('names the desktop in the unrecognised-desktop gap, so support can act on the log', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withDesktop('Hyprland', () => { + withPlatform('linux', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toContain( + 'XDG_CURRENT_DESKTOP=Hyprland' + ) + }) + }) + }) + + it('omits the parenthetical when no desktop is set rather than printing an empty one', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') + withDesktop(undefined, () => { + withPlatform('linux', () => { + const gap = new ElectronSecretStore().describeProtectionGap() + expect(gap).not.toMatch(/XDG_CURRENT_DESKTOP/) + expect(gap).toContain('this desktop uses, so secrets') + }) + }) + }) + + it('still blames the keyring when a real backend was selected but cannot seal', () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + safeStorageMock.getSelectedStorageBackend.mockReturnValue('gnome_libsecret') + withPlatform('linux', () => { + expect(new ElectronSecretStore().describeProtectionGap()).toMatch(/Install and unlock/) + }) + }) }) // Why this shape: the whole safety argument for the SecretStore refactor is that the diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts index 0fd888c2d60..c0804667e5a 100644 --- a/src/main/host/electron-secret-store.ts +++ b/src/main/host/electron-secret-store.ts @@ -19,24 +19,41 @@ export class ElectronSecretStore implements SecretStore { } describeProtectionGap(): string | null { + // Why availability first: it is the call that actually probes the keyring, so every + // backend read below is free and cannot change which call blocks. if (!safeStorage.isEncryptionAvailable()) { // Why platform-specific: the fix differs, and "encryption unavailable" alone // sends users looking in the wrong place. - return process.platform === 'linux' - ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' - : 'The OS keychain is unavailable, so secrets are stored unencrypted.' + if (process.platform !== 'linux') { + return 'The OS keychain is unavailable, so secrets are stored unencrypted.' + } + return readLinuxBackend() === 'basic_text' + ? // Chromium picks the backend from XDG_CURRENT_DESKTOP and recognises none of the + // tiling compositors, so it never asks the secret service that is usually running + // the whole time. Telling these users to install a keyring sends them after one + // they already have. + `Orca could not tell which keyring service this desktop uses${describeDesktop()}, so secrets are stored unencrypted — even if a secret service is already running. Start Orca with --password-store=gnome-libsecret (or --password-store=kwallet6 on KDE) to name one.` + : 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' } - // Why this is not folded into isEncryptionAvailable(): on Linux with no keyring, - // Electron falls back to `basic_text`, which "encrypts" with a hardcoded password. - // It round-trips, so sealing and unsealing genuinely work and must keep working — - // reporting it unavailable would strand every credential already stored this way. - // But it protects nothing, and reporting it as sealed is the actual lie. + // Why this is not folded into isEncryptionAvailable(): `basic_text` "encrypts" with a + // hardcoded password, and Electron makes that key available only after an explicit + // `setUsePlainTextEncryption(true)` (or `--password-store=basic`) — which this app never + // asks for, so the branch is currently unreachable on Linux and stays for the day it is + // not. Where it does apply, sealing round-trips and must keep working: reporting it + // unavailable would strand every credential already stored that way. But it protects + // nothing, and reporting it as sealed is the actual lie. return describeLinuxBackendGap() } } +/** The active desktop, as a parenthetical for support triage, or '' when unset. */ +function describeDesktop(): string { + const desktop = process.env.XDG_CURRENT_DESKTOP?.trim() + return desktop ? ` (XDG_CURRENT_DESKTOP=${desktop})` : '' +} + // Electron omits getSelectedStorageBackend at runtime outside Linux despite its type declaration. -function describeLinuxBackendGap(): string | null { +function readLinuxBackend(): string | null { if (process.platform !== 'linux') { return null } @@ -44,13 +61,15 @@ function describeLinuxBackendGap(): string | null { if (typeof probe !== 'function') { return null } - let backend: string try { - backend = probe.call(safeStorage) + return probe.call(safeStorage) } catch { return null } - return backend === 'basic_text' +} + +function describeLinuxBackendGap(): string | null { + return readLinuxBackend() === 'basic_text' ? 'Secrets are obfuscated with a built-in key, not protected by the OS keyring. Install and unlock gnome-keyring or kwallet, then restart Orca, to seal them properly.' : null } diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts deleted file mode 100644 index 59b7eea3cbc..00000000000 --- a/src/main/ipc/agent-trust.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' -import { ipcMain } from 'electron' -import { - type AgentTrustPreset, - markAntigravityWorkspaceTrusted, - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' -import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' -import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' - -/** - * Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an - * unfamiliar directory behind a "Do you trust this folder?" menu that consumes - * keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL - * paste flow needs the input box, not the menu, so before Orca spawns the - * agent it asks main to write the same trust artifacts the agents write - * after the user accepts. Best-effort: any IO error is swallowed so a failed - * trust write never blocks the workspace from opening. - */ -export function registerAgentTrustHandlers(): void { - ipcMain.removeHandler('agentTrust:markTrusted') - ipcMain.handle( - 'agentTrust:markTrusted', - async ( - _event, - args: { preset: AgentTrustPreset; workspacePath: string; connectionId?: string } - ): Promise => { - if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { - return - } - try { - const connectionId = typeof args.connectionId === 'string' ? args.connectionId.trim() : '' - if (connectionId) { - // Why: SSH-launched agents read trust artifacts from the remote - // user's home, not from this desktop process. - await awaitAgentTrustWriteWithinDeadline( - markRemoteAgentWorkspaceTrusted({ - preset: args.preset, - connectionId, - workspacePath: args.workspacePath - }), - { preset: args.preset, workspacePath: args.workspacePath } - ) - } else if (args.preset === 'qoder') { - markQoderWorkspaceTrusted(args.workspacePath) - } else if (args.preset === 'cursor') { - markCursorWorkspaceTrusted(args.workspacePath) - } else if (args.preset === 'copilot') { - markCopilotFolderTrusted(args.workspacePath) - } else if (args.preset === 'codex') { - await awaitAgentTrustWriteWithinDeadline(markCodexProjectTrusted(args.workspacePath), { - preset: args.preset, - workspacePath: args.workspacePath - }) - } else if (args.preset === 'antigravity') { - markAntigravityWorkspaceTrusted(args.workspacePath) - } - } catch { - // Best-effort: see Why above. The user can still accept the trust - // prompt manually if writing the artifact fails. - } - } - ) -} diff --git a/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts b/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts index 9f84ad85b78..1d26150e520 100644 --- a/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts @@ -12,7 +12,6 @@ import { import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { ExecutionHostId } from '../../shared/execution-host' import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' -import { encodeMergedSearchCursor } from './ai-vault-search-merged-cursor' /** * Three real indexes over real transcripts, wired as three legs. Everything a @@ -256,24 +255,6 @@ it('reports an unreachable host, keeps hasMore, and picks it up on the retry', a expect([...seen].sort()).toEqual(everyKey().sort()) }) -it('refuses a cursor whose page size or host set no longer matches the request', async () => { - const first = resultsOf(await searchAllExecutionHosts({ query: 'needle', limit: 5 }, legs())) - expect( - await searchAllExecutionHosts( - { query: 'needle', limit: 20, cursor: first.page.cursor! }, - legs() - ) - ).toEqual({ kind: 'malformed-cursor' }) - const nonHost = encodeMergedSearchCursor({ - limit: 5, - sort: 'relevance', - hosts: { 'not-a-host': { c: null, e: 0, g: 0 } } - }) - expect( - await searchAllExecutionHosts({ query: 'needle', limit: 5, cursor: nonHost }, legs()) - ).toEqual({ kind: 'malformed-cursor' }) -}) - it('reports a disabled host without aborting the merge', async () => { hosts[2]!.instance.apply({ enabled: false, historyDays: null }) const { keys } = await paginate(5, 'relevance') diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts index 7613fdfbb1f..0858466a009 100644 --- a/src/main/ipc/automations-external-scope.test.ts +++ b/src/main/ipc/automations-external-scope.test.ts @@ -188,15 +188,6 @@ describe('probe scope retention', () => { expect(isExternalAutomationProbeCancelled(error)).toBe(true) probe.release() }) - - it('keeps probing a host that is still retained', async () => { - await invoke('automations:retainExternalScopes', { owners: [desktopSsh()] }) - const entry = (await invoke('automations:listExternalManagerForOwner', { - owner: desktopSsh(), - provider: 'hermes' - })) as { manager: unknown } - expect(entry.manager).not.toBeNull() - }) }) describe('Orca automation traffic priority', () => { diff --git a/src/main/ipc/cli-appimage-stale-registration.test.ts b/src/main/ipc/cli-appimage-stale-registration.test.ts index 927c4e4e1b9..b6789a6aab4 100644 --- a/src/main/ipc/cli-appimage-stale-registration.test.ts +++ b/src/main/ipc/cli-appimage-stale-registration.test.ts @@ -369,13 +369,4 @@ describe('AppImage CLI registration startup repair', () => { await expect(installStatusHandler()()).resolves.toBe(installed) expect(mocks.install).toHaveBeenCalledOnce() }) - - it('does not mutate a stale registration off Linux', async () => { - const stale = status('stale') - mocks.getStatus.mockResolvedValue(stale) - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - - await expect(installStatusHandler()()).resolves.toBe(stale) - expect(mocks.install).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/command-path-resolver.ts b/src/main/ipc/command-path-resolver.ts index e2fe49747e0..1f2934ba8b2 100644 --- a/src/main/ipc/command-path-resolver.ts +++ b/src/main/ipc/command-path-resolver.ts @@ -68,8 +68,16 @@ export async function isCommandOnLocalPath( command: string, options: ResolveCommandOptions = {} ): Promise { + return (await resolveCommandOnLocalPath(command, options)) !== null +} + +/** The absolute path `isCommandOnLocalPath` found, or null. */ +export async function resolveCommandOnLocalPath( + command: string, + options: ResolveCommandOptions = {} +): Promise { if (!command) { - return false + return null } const platform = options.platform ?? process.platform const env = options.env ?? process.env @@ -98,9 +106,9 @@ export async function isCommandOnLocalPath( continue } if (await isExecutableFile(candidate, isWin)) { - return true + return candidate } } } - return false + return null } diff --git a/src/main/ipc/computer-use-permissions.test.ts b/src/main/ipc/computer-use-permissions.test.ts index fea36c778d8..98172833581 100644 --- a/src/main/ipc/computer-use-permissions.test.ts +++ b/src/main/ipc/computer-use-permissions.test.ts @@ -47,25 +47,4 @@ describe('registerComputerUsePermissionHandlers', () => { await expect(registration![1](null, { id: 'accessibility' })).resolves.toBe(result) expect(openComputerUsePermissionsMock).toHaveBeenCalledWith('accessibility') }) - - it('returns computer-use permission status', async () => { - const result = { - platform: 'darwin', - permissions: [ - { id: 'accessibility', status: 'granted' }, - { id: 'screenshots', status: 'not-granted' } - ] - } - getComputerUsePermissionStatusMock.mockReturnValue(result) - - registerComputerUsePermissionHandlers() - - const registration = handleMock.mock.calls.find( - ([channel]) => channel === 'computerUsePermissions:getStatus' - ) - expect(registration).toBeTruthy() - - await expect(registration![1]()).resolves.toBe(result) - expect(getComputerUsePermissionStatusMock).toHaveBeenCalledWith() - }) }) diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index c7495634db1..fd50bd1d6a6 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -347,47 +347,6 @@ describe('registerCrashReportingHandlers', () => { expect(listRecent).not.toHaveBeenCalled() }) - it('uploads crash logs by default after Send Report', async () => { - registerCrashReportingHandlers({ - getById: vi.fn(async () => null), - dismiss: vi.fn(), - markSent: vi.fn(), - markDismissedSent: vi.fn(), - listRecent: vi.fn(async () => []), - record: vi.fn(), - formatDiagnosticText: vi.fn() - } as never) - - const result = await handlers.get('crashReports:submit')?.(null, { - notes: 'manual report', - submitAnonymously: true, - githubLogin: null, - githubEmail: null - }) - - expect(result).toEqual({ - ok: true, - report: null, - diagnosticBundle: { - status: 'attached', - bundleSubmissionId: 'bundleabcdefghijklmnop', - bytes: 25, - spanCount: 1 - } - }) - expect(submitFeedbackMock).toHaveBeenCalledWith( - expect.objectContaining({ - feedback: expect.stringContaining('Status: attached'), - diagnosticBundle: { - bundleSubmissionId: 'bundleabcdefghijklmnop', - content: diagnosticBundle().payload, - bytes: 25, - spanCount: 1 - } - }) - ) - }) - it('marks the report sent when transport retries successfully without diagnostic logs', async () => { const pending = report('pending', 'crash-degraded') const sent = report('sent', pending.id) diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index fcfe2948baf..58828d2af79 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -347,28 +347,6 @@ describe('dashboard payload validation', () => { } }) - // Why: terminalInput is per-card, so a host profile this validator does not - // know must cost that preview its card, never the whole board. - it('drops only the card whose terminal input profile is unusable', () => { - const good = SNAPSHOT.cards[0] - const bad = { - ...good, - paneKey: 'tab-2:leaf-2', - terminalInput: { - hostPlatform: 'plan9', - localWindowsConpty: false, - windowsShiftEnterEncoding: 'csi-u', - ctrlEnterCsiU: false, - kittyKeyboardAdvertised: true - } - } - - const admitted = admitDashboardSnapshot({ ...SNAPSHOT, cards: [good, bad] }) - - expect(admitted?.droppedCardCount).toBe(1) - expect(admitted?.snapshot.cards.map((card) => card.paneKey)).toEqual(['tab-1:leaf-1']) - }) - // Why: the pop-out replays the last accepted snapshot, so rejecting the whole // board over one card froze every other agent's status until it was renamed. describe('admitDashboardSnapshot', () => { @@ -444,22 +422,6 @@ describe('dashboard payload validation', () => { }) ).toBeNull() }) - - it('mirrors isDashboardSnapshot on every snapshot-level rejection', () => { - const cases: unknown[] = [ - { ...SNAPSHOT, generatedAt: Number.NaN }, - { ...SNAPSHOT, cards: 'nope' }, - { ...SNAPSHOT, repoIconsByRepoId: [] }, - { ...SNAPSHOT, showIdle: 'yes' }, - { ...SNAPSHOT, filterOptions: { projects: [], workspaceStatuses: 'nope' } }, - null, - [] - ] - for (const value of cases) { - expect(isDashboardSnapshot(value)).toBe(false) - expect(admitDashboardSnapshot(value)).toBeNull() - } - }) }) // Why: sanitizing an image icon decodes the whole payload to read a 24-byte diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 93d646b7b64..0f717ac1cdf 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -88,12 +88,6 @@ describe('desktop renderer runtime client capabilities', () => { ).toBe(false) }) - it('advertises each capability once so none can be dropped by a stale duplicate', () => { - expect(new Set(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES).size).toBe( - DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.length - ) - }) - it('diverges from the remote Electron list only where a decision was recorded', () => { expect( missingFrom( diff --git a/src/main/ipc/emulator-stream-listener-cleanup.test.ts b/src/main/ipc/emulator-stream-listener-cleanup.test.ts index 16e332ad14a..ea016d9c739 100644 --- a/src/main/ipc/emulator-stream-listener-cleanup.test.ts +++ b/src/main/ipc/emulator-stream-listener-cleanup.test.ts @@ -66,20 +66,6 @@ describe('emulator frame stream listener cleanup', () => { expect(owner.listenerCount('destroyed')).toBe(0) } }) - - it('still tears down via the destroyed event when the window closes without a stop', () => { - registerEmulatorFrameStreamHandlers() - const start = handlers.get('emulator:frameStreamStart')! - const owner = makeOwner() - - start({ sender: owner }, { streamUrl: 'http://127.0.0.1:0/stream' }) - expect(owner.listenerCount('destroyed')).toBe(1) - - owner.emit('destroyed') - // `.once` self-removes after firing; the handler's stop path removes it too - // (idempotent), so no listener survives the window close. - expect(owner.listenerCount('destroyed')).toBe(0) - }) }) describe('emulator video stream listener cleanup', () => { diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts index 9fe72a47b91..7feabb98607 100644 --- a/src/main/ipc/feedback.test.ts +++ b/src/main/ipc/feedback.test.ts @@ -242,17 +242,6 @@ describe('submitFeedback', () => { expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') }) - it('retries a proxy-rejected diagnostic attachment as website JSON', async () => { - fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse()) - - await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ - ok: true, - diagnosticBundleFailure: { status: 403, error: 'status 403' } - }) - expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') - expect(requestInit(1).body).not.toBeInstanceOf(FormData) - }) - it.each([401, 409, 429])( 'does not retry a diagnostic attachment rejected with status %s', async (status) => { @@ -367,18 +356,6 @@ describe('submitFeedback', () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) - it('posts to the website API first so crash reports use the snippet-capable route', async () => { - await submitFeedback({ - feedback: '[Crash Report]', - submissionType: 'crash', - submitAnonymously: true, - githubLogin: null, - githubEmail: null - } as Parameters[0]) - - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback') - }) - it('forces renderer IPC submissions onto the feedback lane', async () => { registerFeedbackHandlers() await handlers.get('feedback:submit')?.(null, { @@ -470,22 +447,6 @@ describe('submitFeedback', () => { }) }) - it('reports unconfirmed delivery when the response body aborts before the deadline', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 202, - json: async () => { - throw new TypeError('terminated') - } - } as unknown as Response) - - await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({ - ok: true, - imagesDelivered: false - }) - expect(requestInit().signal).toMatchObject({ aborted: false }) - }) - it('bounds the image-delivery response body', async () => { fetchMock.mockResolvedValue( new Response('x'.repeat(MAX_FEEDBACK_IMAGE_RESPONSE_BYTES + 1), { status: 202 }) @@ -592,15 +553,6 @@ describe('submitFeedback', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('rejects unsupported image types before any request is made', async () => { - const result = await submitFeedback( - imageSubmitArgs([{ contentType: 'application/pdf', data: new Uint8Array(4) }]) - ) - - expect(result.ok).toBe(false) - expect(fetchMock).not.toHaveBeenCalled() - }) - // Why: the renderer screens types first, so this lane only matters for a // renderer invoking the channel directly — the case the handler guards. it('rejects a prototype member posing as a content type over IPC', async () => { @@ -645,15 +597,6 @@ describe('submitFeedback', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('rejects more images than the supported count', async () => { - const result = await submitFeedback( - imageSubmitArgs(Array.from({ length: 5 }, () => pngImage())) - ) - - expect(result.ok).toBe(false) - expect(fetchMock).not.toHaveBeenCalled() - }) - it('does not fail a crash report over images it was never going to send', async () => { // Why: the crash lane discards images, so validating them there would // abort a crash report the user needs delivered. @@ -670,16 +613,5 @@ describe('submitFeedback', () => { expect(body.getAll('feedbackImage')).toHaveLength(0) expect(body.get('submissionType')).toBe('crash') }) - - it('drops images from crash submissions', async () => { - await submitFeedback({ - ...diagnosticSubmitArgs(), - images: [pngImage()] - } as Parameters[0]) - - const body = requestInit().body as FormData - expect(body.getAll('feedbackImage')).toHaveLength(0) - expect(body.get('diagnosticBundleSubmissionId')).toBe('bundleabcdefghijklmnop') - }) }) }) diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts index 2ba6eaec367..ce4a30c62fa 100644 --- a/src/main/ipc/filesystem-allowed-roots.test.ts +++ b/src/main/ipc/filesystem-allowed-roots.test.ts @@ -285,28 +285,6 @@ describe('getAllowedRoots', () => { expect(isPathInsideOrEqual).toHaveBeenCalledTimes(100_000) }) - it('preserves empty, remote-only, mixed and explicit remote folder scopes in any repo order', () => { - const fixture = makeMixedFixture() - fixture.repos.push( - makeRepo({ - id: 'local-in-remote-group', - path: '/local/mixed', - projectGroupId: 'group-remote' - }) - ) - for (let index = 0; index < fixture.repos.length; index += 1) { - fixture.repos.push(fixture.repos.shift()!) - const { store } = makeCountingStore(fixture) - expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store)) - } - }) - - it('produces the same roots as the pre-change implementation', () => { - const { store } = makeCountingStore(makeMixedFixture()) - - expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store)) - }) - it('reads the store once and indexes project groups once per build', () => { const fixture = makeMixedFixture() const { store, counts } = makeCountingStore(fixture) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 6110ea7baf2..620baa9b595 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -13,6 +13,7 @@ import { // Compatibility exports for runtime command modules that historically imported these seams from // filesystem-auth. The implementations remain owned by their focused modules. export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' +export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation' export { isENOENT } from './filesystem-path-containment' export const PATH_ACCESS_DENIED_MESSAGE = diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 8719c8d0f71..0a7547619b3 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -176,39 +176,6 @@ describe('registerFilesystemHandlers', () => { ) }) - it('prepares the Orca-managed Codex home for the default system selection', async () => { - const context = { - branch: 'feature/ai', - stagedSummary: 'M\tREADME.md', - stagedPatch: '+hello' - } - const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } - resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) - getStagedCommitContextMock.mockResolvedValue(context) - generateCommitMessageFromContextMock.mockResolvedValue({ - success: true, - message: 'Update README' - }) - - registerFilesystemHandlers(store as never, { - prepareForCodexLaunch: () => '/orca-managed/codex-home' - }) - - await handlers.get('git:generateCommitMessage')!(null, { - worktreePath: WORKTREE_FEATURE_PATH - }) - - expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( - context, - params, - expect.objectContaining({ - kind: 'local', - cwd: WORKTREE_FEATURE_PATH, - env: expect.objectContaining({ CODEX_HOME: '/orca-managed/codex-home' }) - }) - ) - }) - it('routes local WSL project commit-message generation through the project runtime target', async () => { await withPlatform('win32', async () => { const context = { diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index 227372c567d..f8e79bc559c 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -513,26 +513,6 @@ describe('registerFilesystemHandlers', () => { expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() }) - it('rejects remote folder downloads when the remote path is not a directory', async () => { - const provider = { - downloadFolder: vi.fn().mockRejectedValue(new Error('Cannot download a file as a folder')) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/file.txt', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('Cannot download a file as a folder') - - expect(provider.downloadFolder).toHaveBeenCalledTimes(1) - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - }) - it('rejects remote folder downloads when the SSH provider cannot transfer folders', async () => { const provider = { stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 66b728b49a2..371a182c506 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -85,19 +85,6 @@ describe('registerFilesystemHandlers', () => { }) }) - it('returns local commit hook failure payload from git:commit', async () => { - commitChangesMock.mockResolvedValue({ success: false, error: 'hook failed' }) - - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('git:commit')!(null, { - worktreePath: WORKTREE_FEATURE_PATH, - message: 'feat: ship commit' - }) - ).resolves.toEqual({ success: false, error: 'hook failed' }) - }) - it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => { const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) @@ -183,19 +170,6 @@ describe('registerFilesystemHandlers', () => { expect(sshFastForwardMock).toHaveBeenCalledWith('/remote/repo', pushTarget) }) - it('rejects git:commit with empty message and does not call commitChanges', async () => { - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('git:commit')!(null, { - worktreePath: WORKTREE_FEATURE_PATH, - message: '' - }) - ).rejects.toThrow('Commit message is required') - - expect(commitChangesMock).not.toHaveBeenCalled() - }) - it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem-import-local-rollback.test.ts b/src/main/ipc/filesystem-import-local-rollback.test.ts index 364c6c88eee..7e196d700dc 100644 --- a/src/main/ipc/filesystem-import-local-rollback.test.ts +++ b/src/main/ipc/filesystem-import-local-rollback.test.ts @@ -23,7 +23,6 @@ vi.mock('node:fs/promises', () => ({ })) import { importOneSource } from './filesystem-import-local' -import { recursiveCopyDir } from './filesystem-import-local-tree-copy' type EntryKind = 'directory' | 'file' | 'symlink' | 'unsupported' @@ -243,12 +242,4 @@ describe('local directory import rollback ownership', () => { expect(rmMock).not.toHaveBeenCalled() expect(unlinkMock).not.toHaveBeenCalled() }) - - it('cleans an owned root when the copy helper is called directly', async () => { - const failure = new Error('read failed') - readdirMock.mockRejectedValue(failure) - - await expect(recursiveCopyDir(source, target)).rejects.toBe(failure) - expect(rmMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: true, force: true }) - }) }) diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 7703cd8a8b1..8047c19f62d 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -240,26 +240,6 @@ describe('fs:importExternalPaths', () => { expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx') }) - it('imports multiple files in one batch', async () => { - const sources = ['/tmp/dropped/a.txt', '/tmp/dropped/b.txt'] - lstatMock.mockImplementation(async (p: string) => { - const resolved = [path.resolve(sources[0]), path.resolve(sources[1])] - if (resolved.includes(p)) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - } - throw enoent() - }) - - const result = (await handlers.get('fs:importExternalPaths')!(null, { - sourcePaths: sources, - destDir - })) as { results: { status: string }[] } - - expect(result.results).toHaveLength(2) - expect(result.results[0]).toMatchObject({ status: 'imported' }) - expect(result.results[1]).toMatchObject({ status: 'imported' }) - }) - it('imports a directory recursively', async () => { const sourcePath = '/tmp/dropped/assets' mockSourceDir(sourcePath, [ diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index be94530352d..448e0188ab3 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -101,17 +101,6 @@ describe('registerFilesystemHandlers', () => { expect(readdirMock).not.toHaveBeenCalled() }) - it('passes the workspace runtime distro into document discovery', async () => { - localOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - registerFilesystemHandlers(store as never) - - await handlers.get('fs:listMarkdownDocuments')!(null, { rootPath: WORKTREE_FEATURE_PATH }) - - expect(listMarkdownDocumentsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { - wslDistro: 'Ubuntu' - }) - }) - it('rejects markdown document listing for authorized but unregistered roots', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 97f62de565c..18d05ab5c96 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -555,15 +555,4 @@ describe('registerFilesystemMutationHandlers', () => { expect(writeFileMock).not.toHaveBeenCalled() }) - - it('propagates fs.rename errors (e.g. ENOENT when source missing)', async () => { - renameMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - - await expect( - handlers.get('fs:rename')!(null, { - oldPath: path.resolve('/workspace/repo/gone.ts'), - newPath: path.resolve('/workspace/repo/new.ts') - }) - ).rejects.toThrow('ENOENT') - }) }) diff --git a/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts b/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts index 79a8c401e9d..25f79ba7f16 100644 --- a/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts +++ b/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts @@ -46,10 +46,6 @@ describe('isENOENT across the SSH relay boundary', () => { expect(isENOENT(throughIpc)).toBe(true) }) - it('recognises an error carrying no code at all', () => { - expect(isENOENT(new Error("ENOENT: no such file or directory, stat '/x'"))).toBe(true) - }) - it('does not treat a different errno as absent', () => { const denied = new Error("EACCES: permission denied, lstat '/root/x'") as NodeJS.ErrnoException denied.code = 'EACCES' diff --git a/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts b/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts index f5348061ab9..54922246cd9 100644 --- a/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts +++ b/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts @@ -97,10 +97,6 @@ afterEach(async () => { }) describe('path containment across Unicode forms', () => { - it('spells the fixture two ways, or the rest of this file proves nothing', () => { - expect(NFC_FOLDER).not.toBe(NFD_FOLDER) - }) - it('accepts a child of a root the filesystem spells the other way', async () => { const scratch = await makeScratchDir() const { onDisk, registered } = await makeOneDirectoryTwoSpellings(scratch) diff --git a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts index e71ea22f7ba..6d62844dc0a 100644 --- a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts +++ b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts @@ -173,17 +173,4 @@ describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => { process.off('unhandledRejection', unhandled) } }) - - it('still rejects the caller when preparation succeeds but the lookup fails', async () => { - const issue = Promise.withResolvers() - const failure = new Error('lookup failed') - linkedLookup.run = () => issue.promise - const result = Promise.resolve( - handlers.get('git:generatePullRequestFields')!(null, request) - ).catch((error: unknown) => error) - await nextTurn() - issue.reject(failure) - expect(await result).toBe(failure) - expect(generatePullRequestFieldsFromContextMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index 3fe70b4e1f2..de32bd8ebb4 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -64,16 +64,6 @@ describe('stageOneSourceForRuntimeUpload', () => { }) }) - it('stages a file with no cap error, where the old buffering path refused', async () => { - const filePath = join(workDir, 'big.bin') - await writeFile(filePath, Buffer.alloc(3 * 1024)) - - await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({ - status: 'staged', - entries: [{ kind: 'file', byteLength: 3 * 1024 }] - }) - }) - it('names the file, the actual size and the limit when a file is over the ceiling', async () => { const filePath = join(workDir, 'clip.mp4') await writeFile(filePath, Buffer.alloc(6 * 1024)) diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 240ca629042..00e69210510 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -59,28 +59,6 @@ describe('local filesystem watcher large batches', () => { await closeAllWatchers() }) - it('accepts a large local watcher event batch without overflowing V8 arguments', async () => { - vi.useFakeTimers() - vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) - const worktreePath = resolve('/tmp/repo') - let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined - vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { - watcherCallback = callback as typeof watcherCallback - return { unsubscribe: vi.fn() } as never - }) - - await handlers['fs:watchWorktree']({ sender: createWatcherSender(1) }, { worktreePath }) - - const events = Array.from({ length: 200_000 }, (_, index): WatcherEvent => ({ - type: 'delete', - path: join(worktreePath, `file-${index}`) - })) - - expect(() => watcherCallback?.(null, events)).not.toThrow() - await closeAllWatchers() - vi.useRealTimers() - }) - it('emits one overflow event for oversized native watcher batches', async () => { vi.useFakeTimers() vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) diff --git a/src/main/ipc/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem-watcher-local-events.test.ts index 343ee2bc3ad..6d9dd1d2299 100644 --- a/src/main/ipc/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem-watcher-local-events.test.ts @@ -69,20 +69,6 @@ describe('local filesystem watcher flush serialization', () => { expect(root.batch.timer).toBeNull() }) - it('flushes sustained batches at the maximum wait', async () => { - const root = await createLocalWatcher('/repo', '/repo') - root.listeners.set(1, sender as never) - watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) - for (let elapsed = 100; elapsed <= WATCH_BATCH_MAX_WAIT_MS; elapsed += 100) { - vi.advanceTimersByTime(100) - expect(sender.send).not.toHaveBeenCalled() - watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) - } - await flushMicrotasks() - expect(sender.send).toHaveBeenCalledTimes(1) - expect(root.batch.timer).toBeNull() - }) - it('cancels a refreshed trailing window without a later flush', async () => { const root = await createLocalWatcher('/repo', '/repo') root.listeners.set(1, sender as never) diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts index 6bd5060fb0e..f884e596f72 100644 --- a/src/main/ipc/filesystem-watcher-real.test.ts +++ b/src/main/ipc/filesystem-watcher-real.test.ts @@ -77,15 +77,6 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { vi.clearAllMocks() }) - // Why: a clear, separate failure mode — if the native addon is missing from - // the bundle the watcher silently no-ops (doInstallLocalWatcher swallows the - // import error), so the wiring assertion below would time out with a vague - // message. This makes "addon absent" distinct from "wiring broken". - it('loads the real @parcel/watcher native addon', async () => { - const watcher = await import('@parcel/watcher') - expect(typeof watcher.subscribe).toBe('function') - }) - // Why: this integration targets the Linux native watcher path described // above; macOS developer sandboxes can load the addon while suppressing // subscribe callbacks, which makes this an environment check instead. diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index db6fcf206e5..464b9ecc831 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -52,13 +52,7 @@ import { import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import { createWslWatcher } from './filesystem-watcher-wsl' -import { createDebouncedBatch } from './filesystem-watcher-batch-control' -import { - MAX_PHYSICAL_WATCHER_CHILDREN, - reserveWatcherChild, - resetWatcherChildRegistryForTest, - WatcherChildCapacityError -} from './parcel-watcher-child-registry' +import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry' import { acquireWatcherRemovalGate } from './watcher-removal-gate' import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' @@ -120,62 +114,6 @@ describe('registerFilesystemWatcherHandlers', () => { await closeAllWatchers() }) - it('automatically retries a WSL watcher when child capacity becomes available', async () => { - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) - const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => - reserveWatcherChild() - ) - vi.mocked(createWslWatcher).mockImplementation(async (_rootKey, worktreePath) => { - const release = reserveWatcherChild() - if (!release) { - throw new WatcherChildCapacityError() - } - return { - subscription: { unsubscribe: vi.fn(async () => release()) }, - listeners: new Map(), - batch: createDebouncedBatch(), - rootPath: worktreePath - } - }) - const sender = createWatcherSender(1) - const args = { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } - - await expect(handlers['fs:watchWorktree']({ sender }, args)).resolves.toBeUndefined() - expect(createWslWatcher).toHaveBeenCalledOnce() - - heldReservations.pop()?.() - await vi.waitFor(() => expect(createWslWatcher).toHaveBeenCalledTimes(2)) - await closeAllWatchers() - heldReservations.forEach((release) => release?.()) - }) - - it('cancels a pending WSL capacity retry when the renderer unwatches', async () => { - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) - const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => - reserveWatcherChild() - ) - vi.mocked(createWslWatcher).mockRejectedValue(new WatcherChildCapacityError()) - const sender = createWatcherSender(1) - const args = { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } - - await handlers['fs:watchWorktree']({ sender }, args) - handlers['fs:unwatchWorktree']({ sender: { id: sender.id } }, args) - heldReservations.pop()?.() - await Promise.resolve() - await Promise.resolve() - - expect(createWslWatcher).toHaveBeenCalledOnce() - heldReservations.forEach((release) => release?.()) - }) - it('rejects installs during destructive removal and allows a retry afterward', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2d452fe4ee6..68946d56cff 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -318,12 +318,6 @@ describe('registerFilesystemHandlers', () => { expect(readFileMock).not.toHaveBeenCalled() }) - it('does not enumerate worktrees when filesystem handlers register', () => { - registerFilesystemHandlers(store as never) - - expect(listWorktreesMock).not.toHaveBeenCalled() - }) - it('rejects writes to directories', async () => { lstatMock.mockResolvedValue({ isDirectory: () => true }) diff --git a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts index 8205381f997..6d86ec820fa 100644 --- a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts @@ -154,20 +154,6 @@ describe('folder repo upgrade poll lifetime', () => { expect(fake.invalidate).toHaveBeenCalledTimes(1) }) - it('keeps active upgrade preparation, cache invalidation and notifications', async () => { - const { store, window } = begin() - await vi.advanceTimersByTimeAsync(25) - expect(store.updateRepo).toHaveBeenCalledWith(root, { - kind: 'git', - folderUpgradeGitRootPath: root, - externalWorktreeVisibility: 'hide' - }) - expect(fake.prepare).toHaveBeenCalledTimes(1) - expect(fake.invalidate).toHaveBeenCalledTimes(1) - expect(fake.reposChanged).toHaveBeenCalledWith(window) - expect(fake.worktreesChanged).toHaveBeenCalledWith(window, root) - }) - it('retries a missing marker and dedupes stable rejected markers', async () => { fake.stat.mockRejectedValueOnce(new Error('ENOENT')) fake.git.mockReturnValue(false) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 1cd92144660..836ff0e7a65 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -51,7 +51,8 @@ vi.mock('./repos/repos-changed-notification', () => ({ notifyReposChanged: vi.fn() })) vi.mock('./registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: vi.fn(async () => {}) @@ -213,28 +214,6 @@ describe('folder repo git upgrade watch', () => { }) }) - it('refuses a project that has folder workspaces the git listing would drop', async () => { - // Why: the git listing branch prunes every lineage id under the repo that `git worktree - // list` does not report, which is all of them — the workspaces would be destroyed. - const repoPath = join(root, 'notebook') - await mkdir(repoPath) - gitInit(repoPath) - const store = makeStore([makeRepo({ id: 'folder-repo', path: repoPath })], { - [`folder-repo::${repoPath}`]: { displayName: 'notebook' }, - [`folder-repo::${repoPath}::workspace:11111111-1111-1111-1111-111111111111`]: { - displayName: 'draft' - } - }) - - startFolderRepoGitUpgradeWatch(store as never, makeWindow() as never, { - pollIntervalMs: POLL_MS, - idlePollIntervalMs: IDLE_POLL_MS - }) - await tick(2) - - expect(store.updateRepo).not.toHaveBeenCalled() - }) - it('retries after the last extra folder workspace is removed', async () => { const repoPath = join(root, 'notebook-cleanup') await mkdir(repoPath) @@ -262,26 +241,6 @@ describe('folder repo git upgrade watch', () => { ) }) - it('still upgrades a project that only has its root workspace', async () => { - const repoPath = join(root, 'solo') - await mkdir(repoPath) - gitInit(repoPath) - const store = makeStore([makeRepo({ id: 'folder-repo', path: repoPath })], { - [`folder-repo::${repoPath}`]: { displayName: 'solo' } - }) - - startFolderRepoGitUpgradeWatch(store as never, makeWindow() as never, { - pollIntervalMs: POLL_MS, - idlePollIntervalMs: IDLE_POLL_MS - }) - await tick(2) - - expect(store.updateRepo).toHaveBeenCalledWith( - 'folder-repo', - expect.objectContaining({ kind: 'git' }) - ) - }) - it('refuses a folder that is inside another repo rather than its own root', async () => { // Why: git accepts any path inside a work tree, so a stray marker in a subdirectory // would otherwise flip a project whose path is not a repository root. @@ -375,21 +334,6 @@ describe('folder repo git upgrade watch', () => { expect(notifyReposChanged).toHaveBeenCalledWith(nextWindow) }) - it('ignores a .git entry git itself does not accept as a repository', async () => { - const repoPath = join(root, 'stray-marker') - await mkdir(repoPath) - await writeFile(join(repoPath, '.git'), 'not a gitdir pointer\n') - const store = makeStore([makeRepo({ id: 'folder-repo', path: repoPath })]) - - startFolderRepoGitUpgradeWatch(store as never, makeWindow() as never, { - pollIntervalMs: POLL_MS, - idlePollIntervalMs: IDLE_POLL_MS - }) - await tick(2) - - expect(store.updateRepo).not.toHaveBeenCalled() - }) - it('upgrades only the folder repo that gained a .git marker', async () => { const pathA = join(root, 'project-a') const pathB = join(root, 'project-b') @@ -429,25 +373,6 @@ describe('folder repo git upgrade watch', () => { expect(store.updateRepo).toHaveBeenCalledTimes(1) }) - it('skips remote and WSL folder repos a local stat cannot answer for', async () => { - const sshPath = join(root, 'ssh-project') - await mkdir(sshPath) - gitInit(sshPath) - const store = makeStore([ - makeRepo({ id: 'ssh-repo', path: sshPath, connectionId: 'conn-1' }), - makeRepo({ id: 'wsl-repo', path: '\\\\wsl$\\Ubuntu\\home\\user\\project' }), - makeRepo({ id: 'runtime-repo', path: sshPath, executionHostId: 'runtime:dev' }) - ]) - - startFolderRepoGitUpgradeWatch(store as never, makeWindow() as never, { - pollIntervalMs: POLL_MS, - idlePollIntervalMs: IDLE_POLL_MS - }) - await tick(2) - - expect(store.updateRepo).not.toHaveBeenCalled() - }) - it('backs off to the idle interval and stats nothing when no folder project exists', async () => { let listCalls = 0 const store = makeStore([makeRepo({ id: 'git-repo', path: join(root, 'g'), kind: 'git' })]) diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts deleted file mode 100644 index 1746890199a..00000000000 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { readFileSync } from 'node:fs' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') - return createGitHubIpcMocks() -}) - -vi.mock('electron', () => mocks.electron) -vi.mock('../github/client', () => mocks.client) -vi.mock('../github/work-item-details', () => mocks.workItemDetails) -vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) -vi.mock('../telemetry/client', () => mocks.telemetry) -vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) -vi.mock('./ui', () => mocks.ui) - -import * as github from './github' -import { createGitHubIpcHarness } from './github-ipc-test-harness' - -const EXPECTED_GITHUB_IPC_CHANNELS = [ - 'gh:prForBranch', - 'gh:refreshPRNow', - 'gh:enqueuePRRefresh', - 'gh:reportVisiblePRRefreshCandidates', - 'gh:issue', - 'gh:listIssues', - 'gh:createIssue', - 'gh:listWorkItems', - 'gh:countWorkItems', - 'gh:workItem', - 'gh:workItemByOwnerRepo', - 'gh:workItemDetails', - 'gh:notifyWorkItemMutated', - 'gh:prFileContents', - 'gh:repoSlug', - 'gh:repoUpstream', - 'gh:prChecks', - 'gh:prCheckDetails', - 'gh:prComments', - 'gh:setPRCommentReaction', - 'gh:resolveReviewThread', - 'gh:setPRFileViewed', - 'gh:addPRReviewCommentReply', - 'gh:addPRReviewComment', - 'gh:updatePRTitle', - 'gh:mergePR', - 'gh:setPRAutoMerge', - 'gh:updatePRState', - 'gh:markPRReadyForReview', - 'gh:rerunPRChecks', - 'gh:requestPRReviewers', - 'gh:removePRReviewers', - 'gh:updateIssue', - 'gh:addIssueComment', - 'gh:listLabels', - 'gh:listAssignableUsers', - 'gh:viewer', - 'gh:checkOrcaStarred', - 'gh:starOrca', - 'gh:rateLimit', - 'gh:diagnoseAuth', - 'gh:listBindableAccounts', - 'gh:validateAccountBinding', - 'gh:listAccessibleProjects', - 'gh:resolveProjectRef', - 'gh:listProjectViews', - 'gh:getProjectViewTable', - 'gh:projectWorkItemDetailsBySlug', - 'gh:updateProjectItemField', - 'gh:clearProjectItemField', - 'gh:updateIssueBySlug', - 'gh:updatePullRequestBySlug', - 'gh:addIssueCommentBySlug', - 'gh:updateIssueCommentBySlug', - 'gh:deleteIssueCommentBySlug', - 'gh:listLabelsBySlug', - 'gh:listAssignableUsersBySlug', - 'gh:listIssueTypesBySlug', - 'gh:updateIssueTypeBySlug' -] as const - -describe('GitHub IPC channel parity', () => { - const harness = createGitHubIpcHarness(mocks) - - beforeEach(harness.reset) - - it('preserves the facade and fixed IPC channel contract', () => { - github.registerGitHubHandlers(harness.store as never, harness.stats as never) - - // The preload facade now composes the two GitHub bridge owners; inspect - // both owners so the channel census remains tied to the actual invokes. - const preloadSource = [ - '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', - '../../preload/api/gh-bridge-mutations-and-projects.ts' - ] - .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) - .join('\n') - const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( - (match) => match[1] - ) - const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( - ([channel]) => channel as string - ) - - expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) - expect(new Set(registeredChannels).size).toBe(registeredChannels.length) - expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) - expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) - }) -}) diff --git a/src/main/ipc/github-issue-source-preference.test.ts b/src/main/ipc/github-issue-source-preference.test.ts index 2c5600bb5e3..ffef0da244b 100644 --- a/src/main/ipc/github-issue-source-preference.test.ts +++ b/src/main/ipc/github-issue-source-preference.test.ts @@ -29,20 +29,6 @@ describe('registerGitHubHandlers', () => { beforeEach(harness.reset) - it('forwards listIssues for registered repositories and unwraps items', async () => { - listIssuesMock.mockResolvedValue({ items: [] }) - - registerGitHubHandlers(store as never, stats as never) - - const result = await handlers['gh:listIssues'](null, { - repoPath: '/workspace/repo', - limit: 5 - }) - - expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined, null) - expect(result).toEqual([]) - }) - it('drops the error field from listIssues envelope at the IPC boundary', async () => { // Why: src/main/ipc/github.ts intentionally unwraps the { items, error? } // envelope to just `items` to preserve the pre-feature-1 diff --git a/src/main/ipc/github-star-telemetry.test.ts b/src/main/ipc/github-star-telemetry.test.ts index 30be879cef8..9dd9a1c1e91 100644 --- a/src/main/ipc/github-star-telemetry.test.ts +++ b/src/main/ipc/github-star-telemetry.test.ts @@ -55,31 +55,6 @@ describe('registerGitHubHandlers', () => { }) }) - it('accepts every app star source for success telemetry', async () => { - starOrcaMock.mockResolvedValue(true) - - registerGitHubHandlers(store as never, stats as never) - - for (const source of [ - 'star_nag', - 'agent_value_moment', - 'onboarding_completed', - 'settings', - 'landing' - ] as const) { - await expect(handlers['gh:starOrca'](null, source)).resolves.toBe(true) - } - - expect(trackMock).toHaveBeenCalledTimes(5) - expect(trackMock.mock.calls.map(([, props]) => props)).toEqual([ - { source: 'star_nag', nth_repo_added: undefined }, - { source: 'agent_value_moment', nth_repo_added: undefined }, - { source: 'onboarding_completed', nth_repo_added: undefined }, - { source: 'settings', nth_repo_added: undefined }, - { source: 'landing', nth_repo_added: undefined } - ]) - }) - it('does not emit app_starred_orca when the star action returns false', async () => { starOrcaMock.mockResolvedValue(false) diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 092886b3fe7..48cff20c9dd 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -173,13 +173,13 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') expect(spawnOptions.envToDelete).toContain('REMOVE_ME') }) - it('prepares Codex project trust before a daemon-backed interactive launch', async () => { + it('prepares Codex launch state before a daemon-backed interactive launch', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( ( _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, _launchEnv?: NodeJS.ProcessEnv, - _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + _launchContext?: { unavailableManagedHomePath?: string } ) => null ) @@ -191,7 +191,6 @@ describe('registerPtyHandlers', () => { }) expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) - expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) }) it('injects explicit proxy settings on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ diff --git a/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts b/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts index cdb744b6d53..0753e637aec 100644 --- a/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts +++ b/src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { onMock, spawnMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers, getPtyRendererDeliveryDebugSnapshot } from './pty' +import { _resetColorQueryReplyColorsForTest } from './pty/provider/registry' +import { _resetPtyOwnerHostColorsForTest } from '../../shared/pty-owner-color-query-colors' +import { _resetTerminalViewAttributesForTest } from '../runtime/terminal-view-attribute-store' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -317,7 +320,7 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) - it('does not answer ordinary terminal OSC color queries in main', async () => { + it('answers a plain shell at the owner, then from the colours the renderer pushes', async () => { vi.useFakeTimers() const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -335,16 +338,40 @@ describe('registerPtyHandlers', () => { })) as { id: string } mainWindow.webContents.send.mockClear() - const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\' + const query = '\x1b]11;?\x1b\\' mockProc.emitData(`${query}ready`) + const pushViewAttributes: unknown = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:terminalViewAttributes' + )?.[1] + if (typeof pushViewAttributes !== 'function') { + throw new Error('pty:terminalViewAttributes listener was not registered') + } + pushViewAttributes(null, { + foreground: [0, 0, 0], + background: [0x12, 0x34, 0x56], + cursor: [0, 0, 0], + ansi: Array.from({ length: 256 }, () => [0, 0, 0]), + colorSchemeMode: 'light', + cursorStyle: 'block', + cursorBlink: false + }) + mockProc.emitData(query) - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write.mock.calls).toEqual([ + ['\x1b]11;rgb:1111/1111/1111\x1b\\'], + ['\x1b]11;rgb:1212/3434/5656\x1b\\'] + ]) vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, - data: `${query}ready` + data: 'ready', + rawLength: `${query}ready${query}`.length, + transformed: true }) } finally { + _resetTerminalViewAttributesForTest() + _resetColorQueryReplyColorsForTest() + _resetPtyOwnerHostColorsForTest() vi.useRealTimers() } }) diff --git a/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts b/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts index 4c74a7a82e9..6d43b0dec35 100644 --- a/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts +++ b/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts @@ -96,7 +96,7 @@ export function createDaemonActiveProviderFixtures(ctx: { getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, launchEnv?: NodeJS.ProcessEnv, - launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + launchContext?: { unavailableManagedHomePath?: string } ) => string | null, getSettings?: () => { httpProxyUrl?: string @@ -157,7 +157,7 @@ export function createDaemonActiveProviderFixtures(ctx: { getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, launchEnv?: NodeJS.ProcessEnv, - launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + launchContext?: { unavailableManagedHomePath?: string } ) => string | null, getSettings?: () => { httpProxyUrl?: string diff --git a/src/main/ipc/pty-ipc-spawn-drivers.ts b/src/main/ipc/pty-ipc-spawn-drivers.ts index 46a19ab9e92..dde291ce072 100644 --- a/src/main/ipc/pty-ipc-spawn-drivers.ts +++ b/src/main/ipc/pty-ipc-spawn-drivers.ts @@ -29,7 +29,7 @@ export function createPtyIpcSpawnDrivers(ctx: { getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, launchEnv?: NodeJS.ProcessEnv, - launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + launchContext?: { unavailableManagedHomePath?: string } ) => string | null, getSettings?: () => { agentStatusHooksEnabled?: boolean diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index c279a239f2a..0e46f2ab6bb 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -601,24 +601,6 @@ describe('pty:management IPC handlers', () => { } ) - // Whether a refresh is worth running is the refresh's own decision; the handler just reports - // whatever evidence is there afterwards. - it('reports a settled allowed verdict unchanged', async () => { - getDaemonFolderAccessMismatchMock.mockReturnValue(evidence('allowed')) - - const result = await readAttribution() - - expect(result.folderAccessMismatch).toEqual(evidence('allowed')) - }) - - it('reports no evidence at all as no mismatch', async () => { - getDaemonFolderAccessMismatchMock.mockReturnValue(null) - - const result = await readAttribution() - - expect(result.folderAccessMismatch).toBeNull() - }) - it('keeps the folder evidence when the refresh throws', async () => { getDaemonFolderAccessMismatchMock.mockReturnValue(evidence('denied')) refreshDaemonFolderAccessProbeMock.mockRejectedValue(new Error('probe exploded')) diff --git a/src/main/ipc/pty-pending-data-drain-contract.ts b/src/main/ipc/pty-pending-data-drain-contract.ts index d1d4c5be1a8..ffe0c0e606f 100644 --- a/src/main/ipc/pty-pending-data-drain-contract.ts +++ b/src/main/ipc/pty-pending-data-drain-contract.ts @@ -1,3 +1,4 @@ +import type { SynchronizedOutputLatchState } from '../../shared/terminal-synchronized-output-scan' import type { Mode2031ReplyScanState } from '../../shared/terminal-color-scheme-protocol' export type PendingPtyData = { @@ -9,6 +10,9 @@ export type PendingPtyData = { droppedOutput?: true droppedMode2031Data?: string droppedMode2031ScanState?: Mode2031ReplyScanState + /** Latch state across bytes the renderer never received, so a drop that swallowed + * a closing \x1b[?2026l can still release xterm's render hold. */ + droppedSynchronizedOutputState?: SynchronizedOutputLatchState projectionAdmissionIds?: readonly string[] projectionAdmissionsTransferred?: true } diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index 1595835801f..4c7204f1980 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -9,7 +9,6 @@ import { } from './pty-ipc-mock-registry' import { posixOnlyIt } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import type { TuiAgent } from '../../shared/tui-agent' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -60,13 +59,13 @@ describe('registerPtyHandlers', () => { const { spawnAndGetEnv } = setupPtyIpcSuite() describe('spawn environment', () => { - it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => { + it('prepares Codex launch state before spawning an interactive tab', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( ( _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, _launchEnv?: NodeJS.ProcessEnv, - _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + _launchContext?: { unavailableManagedHomePath?: string } ) => null ) @@ -82,7 +81,6 @@ describe('registerPtyHandlers', () => { ) expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) - expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) expect(resolveHome.mock.invocationCallOrder[0]).toBeLessThan( spawnMock.mock.invocationCallOrder[0]! ) diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts index 979a50d3008..8c68c01f019 100644 --- a/src/main/ipc/pty/delivery/accept.ts +++ b/src/main/ipc/pty/delivery/accept.ts @@ -9,7 +9,11 @@ import { activeRendererPtys } from './visibility-state' import { PTY_BATCH_INTERVAL_MS } from './constants' -import { appendPendingPtyData, getDroppedMode2031RendererData } from './pending' +import { + appendPendingPtyData, + getDroppedMode2031RendererData, + getDroppedSynchronizedOutputRendererData +} from './pending' import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' import { shouldSendInteractiveOutputNow } from './interactive' import { requestDeliveryResyncForGatedPty } from './accounting' @@ -98,7 +102,13 @@ export function acceptPtyDataForRenderer( pending.droppedOutput === true && !overflowMarkedBeforeAppend && session.pendingOverflowMarkedPtys.has(payload.id) - const nextData = pending.data + getDroppedMode2031RendererData(pending) + // Why the 2026 release goes BEFORE the 2031 data: that payload ends with a + // deliberately-retained INCOMPLETE private-mode sequence (extractPrivateModeScanTail), + // and an ESC after it would abort the dangling CSI and lose the carried mode. + const nextData = + pending.data + + getDroppedSynchronizedOutputRendererData(pending) + + getDroppedMode2031RendererData(pending) const isInteractiveOutput = shouldSendInteractiveOutputNow( payload.id, nextData, diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts index ffe3c3014e8..ede6a6a4302 100644 --- a/src/main/ipc/pty/delivery/flush.ts +++ b/src/main/ipc/pty/delivery/flush.ts @@ -13,9 +13,11 @@ import { } from './constants' import { getDroppedMode2031RendererData, + getDroppedSynchronizedOutputRendererData, pendingProjectionAdmissionOptions, updatePendingProjectionAdmissions } from './pending' +import { resolveSynchronizedOutputSafeSplit } from '../../../../shared/terminal-synchronized-output-scan' import { makePtyDataPayload, sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' import type { PtyIpcSession } from '../session' @@ -144,7 +146,12 @@ export function flushPendingData(session: PtyIpcSession): void { id, { id, - data: pending.data + getDroppedMode2031RendererData(pending), + // 2026 release before the 2031 data: that payload ends with a retained + // partial private-mode sequence an ESC after it would abort. + data: + pending.data + + getDroppedSynchronizedOutputRendererData(pending) + + getDroppedMode2031RendererData(pending), droppedOutput: true }, pending.projectionAdmissionIds @@ -158,8 +165,14 @@ export function flushPendingData(session: PtyIpcSession): void { } const { data } = pending const indivisible = pending.transformed === true - const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) - const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + // Why not a blind offset: splitting inside an open DEC 2026 frame strands + // the closing \x1b[?2026l on a later flush, and xterm stops repainting until + // it arrives or its 1000ms timeout fires. + const splitAt = indivisible + ? data.length + : resolveSynchronizedOutputSafeSplit(data, PTY_BATCH_FLUSH_CHUNK_CHARS) + const chunk = indivisible ? data : data.slice(0, splitAt) + const remaining = indivisible ? '' : data.slice(splitAt) let nextPending: PendingPtyData | undefined if (remaining) { nextPending = { data: remaining } diff --git a/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts b/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts new file mode 100644 index 00000000000..db4922a79b5 --- /dev/null +++ b/src/main/ipc/pty/delivery/interactive-typing-burst.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { INTERACTIVE_OUTPUT_BUDGET_CHARS, INTERACTIVE_OUTPUT_WINDOW_MS } from './constants' +import { shouldSendInteractiveOutputNow } from './interactive' +import { interactiveOutputCharsByPty, lastInputAtByPty } from './visibility-state' + +const PTY_ID = 'pty-typing-burst' + +/** + * A Codex composer repaint: one DEC 2026 synchronized frame per keystroke, + * carrying the closing \x1b[?2026l that releases xterm's render latch. Sized + * from a real 204x52 capture of codex-cli 0.158.0, whose per-keystroke frames + * run from a few dozen bytes up to ~1.4KB. + * + * Why the fast path matters for this shape specifically: xterm renders nothing + * while synchronized output is open and force-flushes only after 1000ms, so a + * repaint diverted to the shared batch flush timer — behind every other pane's + * output — leaves the pane visually frozen rather than merely late. + */ +function codexFrame(bytes: number): string { + return `\x1b[?2026h\x1b[1;1H${'x'.repeat(Math.max(0, bytes - 20))}\x1b[?2026l` +} + +/** + * Mirrors the production input path: `noteRendererPtyInput` + * (src/main/ipc/pty/ipc/write-input.ts:152-155) stamps the input time AND zeroes + * the pty's interactive budget on every keystroke. A test that only stamps the + * time would let the budget accumulate across keys and report a fast-path + * divergence that cannot happen in production. + */ +function noteRendererPtyInput(now: number): void { + lastInputAtByPty.set(PTY_ID, now) + interactiveOutputCharsByPty.set(PTY_ID, 0) +} + +function typeKeyAndRepaint(now: number, frame: string): boolean { + noteRendererPtyInput(now) + return shouldSendInteractiveOutputNow(PTY_ID, frame, now + 2) +} + +describe('interactive output fast path during a continuous typing burst', () => { + beforeEach(() => { + lastInputAtByPty.delete(PTY_ID) + interactiveOutputCharsByPty.delete(PTY_ID) + }) + + it('keeps every repaint of a sustained burst on the fast path', () => { + // 90ms apart: inside INTERACTIVE_OUTPUT_WINDOW_MS, i.e. a fast typist who + // never pauses long enough to expire the window. + const cadenceMs = INTERACTIVE_OUTPUT_WINDOW_MS - 10 + const frame = codexFrame(1400) + const batched: number[] = [] + let now = 1_000 + for (let key = 0; key < 200; key++) { + if (!typeKeyAndRepaint(now, frame)) { + batched.push(key) + } + now += cadenceMs + } + + expect( + batched, + `keys diverted off the interactive fast path at 1400-byte frames: ${batched.join(', ')}` + ).toEqual([]) + }) + + it('still cuts off a single keystroke that triggers a flood', () => { + // The budget is per keystroke, so it must still bound one key's blast + // radius: a TUI dumping megabytes after one keypress cannot ride the + // immediate path past the budget and starve main's timers. + const frame = codexFrame(8 * 1024) + let now = 1_000 + noteRendererPtyInput(now) + let admitted = 0 + while (shouldSendInteractiveOutputNow(PTY_ID, frame, now + 2)) { + admitted += 1 + // No new keystroke: the same key's repaints keep arriving. + now += 1 + } + expect(admitted * frame.length).toBeLessThanOrEqual(INTERACTIVE_OUTPUT_BUDGET_CHARS) + expect(admitted).toBeGreaterThan(0) + }) + + it('leaves the fast path when output arrives long after the last keystroke', () => { + const frame = codexFrame(1400) + const now = 1_000 + noteRendererPtyInput(now) + // Unprompted output, well past the input window: throughput work, not echo. + expect( + shouldSendInteractiveOutputNow(PTY_ID, frame, now + INTERACTIVE_OUTPUT_WINDOW_MS + 1) + ).toBe(false) + }) +}) diff --git a/src/main/ipc/pty/delivery/pending.ts b/src/main/ipc/pty/delivery/pending.ts index ff14ccc8c52..b6a7096ca3c 100644 --- a/src/main/ipc/pty/delivery/pending.ts +++ b/src/main/ipc/pty/delivery/pending.ts @@ -4,6 +4,10 @@ import { scanMode2031ReplyDecision, type Mode2031ReplyScanState } from '../../../../shared/terminal-color-scheme-protocol' +import { + INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE, + advanceDroppedSynchronizedOutputLatch +} from '../../../../shared/terminal-synchronized-output-scan' import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' import { recordCrashBreadcrumb } from '../../../crash-reporting/crash-breadcrumb-store' import { terminalOutputBacklogCapChars } from '../../../../shared/terminal-scrollback-policy' @@ -52,6 +56,13 @@ export function getDroppedMode2031RendererData(pending: PendingPtyData): string return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail } +/** Releases xterm's DEC 2026 render hold when the dropped span left a frame open. + * Without it the pane freezes on its last painted frame until xterm's 1000ms + * forced flush. */ +export function getDroppedSynchronizedOutputRendererData(pending: PendingPtyData): string { + return pending.droppedSynchronizedOutputState?.active === true ? '\x1b[?2026l' : '' +} + export function pendingProjectionAdmissionOptions(session: PtyIpcSession) { return { isPending: (id: string) => session.sshOutputIntake?.hasUnpublishedProjection(id) ?? false, @@ -117,12 +128,17 @@ export function dropOversizedPendingPtyData( session.sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') } const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) + const synchronizedOutput = advanceDroppedSynchronizedOutputLatch( + pending.data, + INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE + ) // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), droppedOutput: true, droppedMode2031Data: mode2031.data, - droppedMode2031ScanState: mode2031.state + droppedMode2031ScanState: mode2031.state, + droppedSynchronizedOutputState: synchronizedOutput.state } } @@ -147,6 +163,10 @@ export function appendPendingPtyData( data, existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE ) + const synchronizedOutput = advanceDroppedSynchronizedOutputLatch( + data, + existing.droppedSynchronizedOutputState ?? INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE + ) const remainingQueryCapacity = Math.max( 0, DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length @@ -156,7 +176,8 @@ export function appendPendingPtyData( ...existing, data: existing.data + salvaged, droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, - droppedMode2031ScanState: mode2031.state + droppedMode2031ScanState: mode2031.state, + droppedSynchronizedOutputState: synchronizedOutput.state } } const projectionState = compactPendingProjectionState( diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index de61800c7ef..7d825062903 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -286,7 +286,8 @@ export function buildPtyHostEnv( // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath const preflightCommand = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, + hooksEnabled: + opts.agentStatusHooksEnabled && isTuiAgentEnabled('codex', opts.disabledTuiAgents), isPackaged: opts.isPackaged, isWsl: opts.isWsl, managedHomePath: opts.selectedCodexHomePath, diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index 7abde336181..fbc0db2a04d 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -1,7 +1,5 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' import { isCodexHomeAuthReadyForLaunch, waitForManagedCodexAuthReady @@ -31,12 +29,6 @@ export function shouldSkipCodexHomeEnvForWindowsShell( return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) } -export function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { - return ( - isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) - ) -} - // Why: with the real-home flag ON, a host system-default launch resolves to a // null managed home. Signal the env builder to strip a nested-Orca-inherited // override instead of injecting one, so Codex runs on the user's own ~/.codex. diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index 62e199e09c3..a7118cb08b8 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -30,7 +30,6 @@ export type PrepareCodexResumeHomeArgs = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string } export function prepareCodexResumeHome( @@ -49,8 +48,7 @@ export function prepareCodexResumeHome( preparation: prepareCodexSessionResume({ providerSession, target: args.target, - launchEnv: args.launchEnv, - workspacePath: args.workspacePath + launchEnv: args.launchEnv }) } } diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 37ef0614a50..20981d0cce4 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -29,7 +29,6 @@ export type BuildPtyHostEnvOptions = { agentStatusHooksEnabled: boolean /** Per-agent opt-out; disabled agents must not receive managed extensions. */ disabledTuiAgents?: Iterable | null - codexStatusHooksEnabled?: boolean networkProxySettings?: NetworkProxySettings /** Headless paired runtimes hand browser launches to the client-hosted Orca browser. */ routeBrowserOpensToClient?: boolean @@ -38,8 +37,6 @@ export type BuildPtyHostEnvOptions = { } export type CodexHomeLaunchContext = { - workspacePath?: string - launchAgent?: TuiAgent unavailableManagedHomePath?: string } @@ -56,7 +53,6 @@ export type PrepareCodexSessionResume = (args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }) => Promise export type CodexHomePtySpawnedLifecycleArgs = { diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index 62e74738b72..eae199c315f 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -5,8 +5,7 @@ import type { PtyRendererDeliveryStateReport } from '../../../../shared/pty-renderer-delivery-health' import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' -import { setTerminalViewAttributes } from '../../../runtime/terminal-view-attribute-store' -import { validateTerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { installTerminalViewAttributesIpc } from './terminal-view-attributes-ipc' import { recordHiddenRendererPtyDataDrop, setRendererPtyDeliveryInterest, @@ -284,14 +283,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } }) - ipcMain.removeAllListeners('pty:terminalViewAttributes') - ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { - // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. - const attributes = validateTerminalViewAttributes(args) - if (attributes) { - setTerminalViewAttributes(attributes) - } - }) + installTerminalViewAttributesIpc(session) ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 50173f93c3d..b69bc65fed2 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -13,7 +13,6 @@ import { resolveCodexHomeAfterManagedAuthReadiness, shouldSkipCodexHomeEnvForWindowsShell, shouldStripInheritedOrcaCodexHome, - isCodexStatusHooksEnabled, codexHomePathsEqual } from '../host-env/codex-home' import { promoteAgentTeamsShimPath } from '../host-env/path' @@ -40,8 +39,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis launchAgent: args.launchAgent, providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, - launchEnv: ctx.baseEnv, - workspacePath: ctx.cwd + launchEnv: ctx.baseEnv }) ctx.codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -57,10 +55,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis // pre-strip env — only the daemon branch below re-derives this from baseEnv. ctx.env = ctx.baseEnv const selectLaunchCodexHome = async (): Promise => - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv)) ?? null ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( @@ -84,17 +79,12 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis resolveCurrent: async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: 'codex' - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv)) ?? null ), resolveAfterUnavailable: async (unavailableManagedHomePath) => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: 'codex', unavailableManagedHomePath })) ?? null ) @@ -152,7 +142,6 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: diff --git a/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts b/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts new file mode 100644 index 00000000000..0375ca7f88f --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { buildRuntimePtySpawnOptions } from '../runtime/spawn-options' +import { createRuntimePtySpawnState } from '../runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../runtime/controller-deps' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { createPtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcDeps } from './spawn-types' + +function settingsWith(isolation: boolean | undefined): GlobalSettings { + const settings = getDefaultSettings('/tmp') + if (isolation === undefined) { + delete settings.codexTerminalServerIsolation + return settings + } + return { ...settings, codexTerminalServerIsolation: isolation } +} + +async function ipcSpawnEnv( + isolation: boolean | undefined, + connectionId: string | null +): Promise | undefined> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyIpcSpawnOptions only reads the members stubbed here; the rest belong to later spawn stages this test never runs. + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + getSettings: () => settingsWith(isolation), + runtime: { registerPreAllocatedHandleForPty: vi.fn() } + } as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { cols: 80, rows: 24, connectionId }) + ctx.env = { KEEP: '1' } + await buildPtyIpcSpawnOptions(ctx) + return ctx.spawnOptions.env +} + +async function runtimeSpawnEnv( + isolation: boolean | undefined, + connectionId: string | null +): Promise | undefined> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildRuntimePtySpawnOptions only reads getSettings here; the rest belong to later spawn stages this test never runs. + const deps = { getSettings: () => settingsWith(isolation) } as unknown as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { cols: 80, rows: 24, connectionId }) + ctx.env = { KEEP: '1' } + await buildRuntimePtySpawnOptions(ctx) + return ctx.spawnOptions.env +} + +// Why both hosts: SSH panes reach the relay with this env untouched, local ones reach +// node-pty or the daemon; WSL forwarding is covered by wsl-orca-env.test.ts. +describe.each([ + ['renderer spawn', ipcSpawnEnv], + ['runtime spawn', runtimeSpawnEnv] +])('%s: Codex terminal server isolation', (_name, spawnEnv) => { + it.each([null, 'ssh-1'])( + 'injects nothing while the setting is on (connection %s)', + async (id) => { + expect(await spawnEnv(true, id)).toEqual({ KEEP: '1' }) + expect(await spawnEnv(undefined, id)).toEqual({ KEEP: '1' }) + } + ) + + it.each([null, 'ssh-1'])( + 'opts new terminals out with ORCA_CODEX_ISOLATE=0 when off (connection %s)', + async (id) => { + expect(await spawnEnv(false, id)).toEqual({ KEEP: '1', ORCA_CODEX_ISOLATE: '0' }) + } + ) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d46f210fcd3..d0a3ba8cef1 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -23,16 +23,21 @@ import { ptySizes } from '../delivery/visibility-state' import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' +import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation' +import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' +import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( ctx: PtyIpcSpawnState ): Promise<{ isReattach: true } | null> { const args = ctx.args - ctx.spawnEnv = ctx.preAllocatedHandle - ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } - : ctx.env + // Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env. + ctx.spawnEnv = withCodexTerminalServerIsolationEnv( + ctx.preAllocatedHandle ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } : ctx.env, + ctx.deps.getSettings?.() + ) const envToDelete = ctx.claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined @@ -77,8 +82,17 @@ export async function buildPtyIpcSpawnOptions( if (ctx.combinedEnvToDelete) { ctx.spawnOptions.envToDelete = ctx.combinedEnvToDelete } - if (ctx.launchCommand !== undefined) { - ctx.spawnOptions.command = ctx.launchCommand + const noDaemonLaunch = planCodexNoDaemonLaunch({ + command: ctx.launchCommand, + executesOnThisHost: !args.connectionId && ctx.codexSelectionTarget.runtime !== 'wsl', + shellOverride: ctx.effectiveShellOverride, + env: ctx.spawnEnv, + envToDelete: ctx.combinedEnvToDelete, + cwd: ctx.cwd + }) + const launchCommand = noDaemonLaunch ? await noDaemonLaunch : ctx.launchCommand + if (launchCommand !== undefined) { + ctx.spawnOptions.command = launchCommand } if (args.commandDelivery !== undefined) { ctx.spawnOptions.commandDelivery = args.commandDelivery @@ -92,6 +106,22 @@ export async function buildPtyIpcSpawnOptions( if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } + const trustWrite = applyAgentWorkspaceTrustToSpawn({ + launchAgent: args.launchAgent, + worktreeId: args.worktreeId, + cwd: ctx.cwd, + store: ctx.deps.store, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + settings: ctx.deps.getSettings?.(), + env: ctx.spawnEnv, + claudeAuth: ctx.claudeAuth, + wslDistro: ctx.expectedWslDistro, + connectionId: args.connectionId ?? null, + spawnOptions: ctx.spawnOptions + }) + if (trustWrite) { + await trustWrite + } if (ctx.reservationPaneKey) { ctx.spawnOptions.paneKey = ctx.reservationPaneKey } diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index e14b469d1fb..d1f340be17d 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -104,7 +104,6 @@ export type PtySpawnIpcDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }) => PreparedCodexResumeHome | null noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch resolveCodexResumeLaunch: ( diff --git a/src/main/ipc/pty/ipc/terminal-view-attributes-ipc.test.ts b/src/main/ipc/pty/ipc/terminal-view-attributes-ipc.test.ts new file mode 100644 index 00000000000..35bdf90e5ae --- /dev/null +++ b/src/main/ipc/pty/ipc/terminal-view-attributes-ipc.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { + _resetTerminalViewAttributesForTest, + getTerminalViewAttributes, + getTerminalViewerColors, + setPairedViewerColors, + setTerminalViewAttributes, + setTerminalViewerColorsListener +} from '../../../runtime/terminal-view-attribute-store' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { + _resetColorQueryReplyColorsForTest, + getLocalPtyProvider, + setLocalPtyProvider +} from '../provider/registry' +import { installTerminalViewAttributesIpc } from './terminal-view-attributes-ipc' + +class RecordingProvider extends LocalPtyProvider { + readonly pushes: TerminalOscColorQueryReplyColors[] = [] + + override setColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void { + this.pushes.push(colors) + } +} + +const DESKTOP_ATTRIBUTES: TerminalViewAttributes = { + foreground: [0, 0, 0], + background: [0x12, 0x34, 0x56], + cursor: [0, 0, 0], + ansi: Array.from({ length: 256 }, (): [number, number, number] => [0, 0, 0]), + colorSchemeMode: 'light', + cursorStyle: 'block', + cursorBlink: false +} +const DESKTOP = { foreground: '#000000', background: '#123456' } +const CLIENT = { foreground: '#2e3434', background: '#ffffff' } + +describe('seeding PTY owner colours from saved settings', () => { + const originalLocal = getLocalPtyProvider() + const settings = getDefaultSettings('/tmp') + + afterEach(() => { + _resetTerminalViewAttributesForTest() + _resetColorQueryReplyColorsForTest() + setLocalPtyProvider(originalLocal) + }) + + it('seeds a light-appearance host with its light theme before any renderer push', () => { + const owner = new RecordingProvider() + setLocalPtyProvider(owner) + + installTerminalViewAttributesIpc({ + getSettings: () => ({ ...settings, theme: 'system' }), + options: { systemPrefersDark: () => false } + }) + + expect(owner.pushes).toEqual([{ foreground: '#2e3434', background: '#ffffff' }]) + }) + + it('seeds a host with no display from its saved dark theme', () => { + const owner = new RecordingProvider() + setLocalPtyProvider(owner) + + installTerminalViewAttributesIpc({ getSettings: () => ({ ...settings, theme: 'dark' }) }) + + expect(owner.pushes).toEqual([{ foreground: '#ffffff', background: '#282c34' }]) + }) + + it('publishes colours a renderer already pushed instead of the saved theme', () => { + setTerminalViewAttributes(DESKTOP_ATTRIBUTES) + const owner = new RecordingProvider() + setLocalPtyProvider(owner) + + installTerminalViewAttributesIpc({ getSettings: () => settings }) + + expect(owner.pushes).toEqual([DESKTOP]) + }) +}) + +describe('one viewer colour value for every PTY owner', () => { + const originalLocal = getLocalPtyProvider() + const settings = getDefaultSettings('/tmp') + const SEED = { foreground: '#ffffff', background: '#282c34' } + + afterEach(() => { + _resetTerminalViewAttributesForTest() + _resetColorQueryReplyColorsForTest() + setLocalPtyProvider(originalLocal) + }) + + function install(): RecordingProvider { + const owner = new RecordingProvider() + setLocalPtyProvider(owner) + installTerminalViewAttributesIpc({ getSettings: () => ({ ...settings, theme: 'dark' }) }) + return owner + } + + it("keeps this host's own window theme when a paired client pushes", () => { + const owner = install() + setTerminalViewAttributes(DESKTOP_ATTRIBUTES) + + setPairedViewerColors(CLIENT) + + expect(getTerminalViewerColors()).toEqual(DESKTOP) + expect(owner.pushes).toEqual([SEED, DESKTOP]) + expect(getTerminalViewAttributes()).toBe(DESKTOP_ATTRIBUTES) + }) + + it("answers with a paired client's theme on a host with no window of its own", () => { + const owner = install() + + setPairedViewerColors(CLIENT) + + expect(getTerminalViewerColors()).toEqual(CLIENT) + expect(owner.pushes).toEqual([SEED, CLIENT]) + }) + + it("switches to this host's window theme once its renderer pushes", () => { + const owner = install() + setPairedViewerColors(CLIENT) + + setTerminalViewAttributes(DESKTOP_ATTRIBUTES) + setPairedViewerColors({ foreground: '#111111', background: '#eeeeee' }) + + expect(getTerminalViewerColors()).toEqual(DESKTOP) + expect(owner.pushes).toEqual([SEED, CLIENT, DESKTOP]) + }) + + it('falls back to the saved theme until any viewer reports', () => { + const owner = install() + + expect(getTerminalViewerColors()).toEqual(SEED) + expect(owner.pushes).toEqual([SEED]) + }) + + it('notifies once per change of the answered colours', () => { + install() + const published: unknown[] = [] + setTerminalViewerColorsListener((colors) => published.push(colors)) + + setPairedViewerColors(CLIENT) + setPairedViewerColors({ ...CLIENT }) + setTerminalViewAttributes(DESKTOP_ATTRIBUTES) + setTerminalViewAttributes({ ...DESKTOP_ATTRIBUTES, cursorBlink: true }) + setPairedViewerColors({ foreground: '#111111', background: '#eeeeee' }) + + expect(published).toEqual([CLIENT, DESKTOP]) + }) +}) diff --git a/src/main/ipc/pty/ipc/terminal-view-attributes-ipc.ts b/src/main/ipc/pty/ipc/terminal-view-attributes-ipc.ts new file mode 100644 index 00000000000..0db98bfc09b --- /dev/null +++ b/src/main/ipc/pty/ipc/terminal-view-attributes-ipc.ts @@ -0,0 +1,38 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { + getTerminalViewerColors, + seedTerminalViewerColors, + setTerminalViewAttributes, + setTerminalViewerColorsListener +} from '../../../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' +import { publishColorQueryReplyColors } from '../provider/registry' +import type { PtyIpcSession } from '../session' + +export function installTerminalViewAttributesIpc( + session: Pick +): void { + setTerminalViewerColorsListener(publishColorQueryReplyColors) + const settings = session.getSettings?.() + // Why seed from settings: a headless host may never hear from a viewer, and a desktop pane + // can query before the first push lands; either way the owner should answer with the saved theme. + if (settings) { + seedTerminalViewerColors( + resolveConfiguredTerminalColors(settings, session.options?.systemPrefersDark?.() ?? true) + ) + } + const current = getTerminalViewerColors() + if (current) { + publishColorQueryReplyColors(current) + } + const ipcMain = getPtyIpc() + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: a malformed palette would give TUIs a wrong color reply. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) +} diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index bf07eddf0bb..310a15f0ae2 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -14,7 +14,6 @@ import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, - isCodexStatusHooksEnabled, shouldStripInheritedOrcaCodexHome } from '../host-env/codex-home' import type { GetSelectedCodexHomePath } from '../host-env/types' @@ -50,10 +49,7 @@ export function configureLocalPtyProvider(args: { codexSelectionTarget, ctx?.codexHomePathOverride ? ctx.codexHomePathOverride.value - : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - })) ?? null) + : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv)) ?? null) ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const ptySettings = getSettings?.() @@ -89,7 +85,6 @@ export function configureLocalPtyProvider(args: { wslDistro: ctx?.wslDistro ?? null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.() }) diff --git a/src/main/ipc/pty/provider/registry-color-query-reply-colors.test.ts b/src/main/ipc/pty/provider/registry-color-query-reply-colors.test.ts new file mode 100644 index 00000000000..048da278a5e --- /dev/null +++ b/src/main/ipc/pty/provider/registry-color-query-reply-colors.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' +import { + _resetColorQueryReplyColorsForTest, + getLocalPtyProvider, + publishColorQueryReplyColors, + registerSshPtyProvider, + setLocalPtyProvider, + unregisterSshPtyProvider +} from './registry' + +class RecordingProvider extends LocalPtyProvider { + readonly pushes: TerminalOscColorQueryReplyColors[] = [] + + override setColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void { + this.pushes.push(colors) + } +} + +const DARK = { foreground: '#ffffff', background: '#000000' } +const LIGHT = { foreground: '#000000', background: '#ffffff' } + +describe('PTY owner colour publication', () => { + const originalLocal = getLocalPtyProvider() + + afterEach(() => { + _resetColorQueryReplyColorsForTest() + unregisterSshPtyProvider('ssh-colors') + setLocalPtyProvider(originalLocal) + }) + + it('pushes every change to each owner, and the latest to owners that register later', () => { + const local = new RecordingProvider() + setLocalPtyProvider(local) + const early = new RecordingProvider() + registerSshPtyProvider('ssh-colors', early) + // Why nothing yet: pushing "unknown" would wipe the theme a surviving daemon still holds. + expect([...local.pushes, ...early.pushes]).toEqual([]) + + publishColorQueryReplyColors(DARK) + publishColorQueryReplyColors(LIGHT) + const late = new RecordingProvider() + registerSshPtyProvider('ssh-colors', late) + + expect(local.pushes).toEqual([DARK, LIGHT]) + expect(early.pushes).toEqual([DARK, LIGHT]) + expect(late.pushes).toEqual([LIGHT]) + }) + + it('does not re-push colours every owner already has', () => { + const local = new RecordingProvider() + setLocalPtyProvider(local) + + publishColorQueryReplyColors(DARK) + publishColorQueryReplyColors({ ...DARK }) + + expect(local.pushes).toEqual([DARK]) + }) +}) diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts index 2a3d3b162fd..8d27770c11c 100644 --- a/src/main/ipc/pty/provider/registry.ts +++ b/src/main/ipc/pty/provider/registry.ts @@ -2,6 +2,8 @@ import { LocalPtyProvider } from '../../../providers/local-pty-provider' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership } from './ownership-state' +import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' +import { colorQueryReplyColorsEqual } from '../../../../shared/pty-owner-color-query-colors' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -9,6 +11,36 @@ import { ptyOwnership } from './ownership-state' export let localProvider: IPtyProvider = new LocalPtyProvider() export const sshProviders = new Map() export const sshProvidersByGeneration = new Map() +let colorQueryReplyColors: TerminalOscColorQueryReplyColors | null = null + +// Why push to every owner: each process that owns PTYs (in-process, daemon, relay) answers +// OSC 10/11 itself, so a theme change must reach it before its next query, not at spawn. +// Why never push "unknown": a daemon outlives the app and keeps the last run's theme. +function pushColorQueryReplyColors(provider: IPtyProvider): void { + if (!colorQueryReplyColors) { + return + } + try { + provider.setColorQueryReplyColors?.(colorQueryReplyColors) + } catch { + /* Best-effort; the owner keeps answering from its last or default colours. */ + } +} + +export function publishColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void { + // Why: a re-install republishes unchanged colours; that must not re-notify every daemon and relay. + if (colorQueryReplyColorsEqual(colorQueryReplyColors, colors)) { + return + } + colorQueryReplyColors = colors + for (const { provider } of registeredPtyProviders()) { + pushColorQueryReplyColors(provider) + } +} + +export function _resetColorQueryReplyColorsForTest(): void { + colorQueryReplyColors = null +} export type RegisteredPtyProvider = { provider: IPtyProvider @@ -101,6 +133,7 @@ export function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider /** Register an SSH PTY provider for a connection. */ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { sshProviders.set(connectionId, provider) + pushColorQueryReplyColors(provider) const generation = (provider as { providerGeneration?: number }).providerGeneration if (Number.isSafeInteger(generation) && generation! > 0) { sshProvidersByGeneration.set(generation!, provider) @@ -133,4 +166,5 @@ export function getLocalPtyProvider(): IPtyProvider { * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ export function setLocalPtyProvider(provider: IPtyProvider): void { localProvider = provider + pushColorQueryReplyColors(provider) } diff --git a/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts b/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts deleted file mode 100644 index 67bee9ca700..00000000000 --- a/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * The two spawn preflights are twins: one serves renderer/IPC spawns, the other serves runtime - * spawns (`terminal.create` from the CLI, headless serve, and paired remote environments). They - * had drifted — the runtime twin passed a literal `undefined` for the caller's shell, so on a - * Windows host a runtime-created terminal could only ever be the host's default shell. A caller - * asking for cmd or PowerShell had to send it as `command`, which the provider TYPES into that - * default shell: the pty stayed the default shell with the requested one running inside it, and - * leaving that child dropped the caller's handle back onto a prompt it never asked for. - * - * Source-level because the functional seam is a whole spawn pipeline; what actually regressed is - * one twin silently not reading a field the other reads. - */ -const PREFLIGHTS = ['ipc', 'runtime'] as const - -describe.each(PREFLIGHTS)('%s pty spawn preflight', (lane) => { - const source = readFileSync(join(__dirname, lane, 'spawn-preflight.ts'), 'utf8') - - it("resolves Windows terminal runtime options from the caller's requested shell", () => { - expect(source).toContain('requestedShellOverride: args.shellOverride') - expect(source).not.toContain('requestedShellOverride: undefined') - }) -}) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index e1bfd5d7f10..a2dcc21bada 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -31,7 +31,6 @@ export type PtyRuntimeControllerDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }) => PreparedCodexResumeHome | null resolveCodexResumeLaunch: ( command: string | undefined, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 933624e6dd5..5f64779b364 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -23,6 +23,8 @@ import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' +import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation' +import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import { resolveStablePaneOwner } from '../pane/stable-owner' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { @@ -31,6 +33,7 @@ import { paneSpawnReservationsByOwnerKey } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' +import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -40,6 +43,8 @@ export async function buildRuntimePtySpawnOptions( > { const args = ctx.args + // Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env. + ctx.env = withCodexTerminalServerIsolationEnv(ctx.env, ctx.deps.getSettings?.()) const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined @@ -98,8 +103,17 @@ export async function buildRuntimePtySpawnOptions( } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) - if (ctx.launchCommand !== undefined) { - ctx.spawnOptions.command = ctx.launchCommand + const noDaemonLaunch = planCodexNoDaemonLaunch({ + command: ctx.launchCommand, + executesOnThisHost: !args.connectionId && ctx.codexSelectionTarget.runtime !== 'wsl', + shellOverride: ctx.daemonShellOverride, + env: ctx.env, + envToDelete: ctx.spawnOptions.envToDelete, + cwd: ctx.cwd + }) + const launchCommand = noDaemonLaunch ? await noDaemonLaunch : ctx.launchCommand + if (launchCommand !== undefined) { + ctx.spawnOptions.command = launchCommand } if (args.commandDelivery !== undefined) { ctx.spawnOptions.commandDelivery = args.commandDelivery @@ -113,6 +127,22 @@ export async function buildRuntimePtySpawnOptions( if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } + const trustWrite = applyAgentWorkspaceTrustToSpawn({ + launchAgent: args.launchAgent, + worktreeId: args.worktreeId, + cwd: ctx.cwd, + store: ctx.deps.store, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + settings: ctx.deps.getSettings?.(), + env: ctx.env, + claudeAuth: ctx.claudeAuth, + wslDistro: ctx.expectedWslDistro, + connectionId: args.connectionId ?? null, + spawnOptions: ctx.spawnOptions + }) + if (trustWrite) { + await trustWrite + } ctx.hadSessionSizeBeforeAttach = ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false ctx.sessionSizeBeforeAttach = diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 2bd10feaab6..484f9166e4b 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -12,7 +12,6 @@ import { resolveCodexHomeAfterManagedAuthReadiness, shouldSkipCodexHomeEnvForWindowsShell, shouldStripInheritedOrcaCodexHome, - isCodexStatusHooksEnabled, codexHomePathsEqual } from '../host-env/codex-home' import { promoteAgentTeamsShimPath } from '../host-env/path' @@ -137,8 +136,7 @@ export async function prepareRuntimePtySpawn( launchAgent: args.launchAgent, providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, - launchEnv: args.env, - workspacePath: ctx.cwd + launchEnv: args.env }) const codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -188,10 +186,7 @@ export async function prepareRuntimePtySpawn( ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } const selectLaunchCodexHome = async (): Promise => - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env)) ?? null ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( @@ -219,17 +214,12 @@ export async function prepareRuntimePtySpawn( resolveCurrent: async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: 'codex' - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env)) ?? null ), resolveAfterUnavailable: async (unavailableManagedHomePath) => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: 'codex', unavailableManagedHomePath })) ?? null ) @@ -286,7 +276,6 @@ export async function prepareRuntimePtySpawn( wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index be666fc6248..6ddd4d86713 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -55,6 +55,8 @@ export type PtyIpcSessionOptions = { isRecoveryReloadInFlight?: (webContentsId: number) => boolean onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void onPtyExit?: (id: string, exitSequence: number) => void + /** The OS appearance, for a 'system' theme; absent on a host with no display. */ + systemPrefersDark?: () => boolean } export type PtyRendererDelivery = Pick< diff --git a/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts b/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts new file mode 100644 index 00000000000..3a8096c5309 --- /dev/null +++ b/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth/runtime-auth-types' +import { getDefaultSettings } from '../../../shared/constants' +import type { TuiAgent } from '../../../shared/tui-agent' + +const applyAgentWorkspaceTrust = vi.hoisted(() => + vi.fn<(preset: string, path: string, context: unknown) => Promise>(async () => ({})) +) +vi.mock('../../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) + +import { buildPtyIpcSpawnOptions } from './ipc/spawn-options' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './ipc/spawn-state' +import type { AdoptStablePaneResult, PtySpawnIpcDeps } from './ipc/spawn-types' +import { buildRuntimePtySpawnOptions } from './runtime/spawn-options' +import { createRuntimePtySpawnState } from './runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' + +type BuildInput = { + worktreeId?: string + cwd?: string + connectionId?: string + launchAgent?: TuiAgent + command?: string + restored?: boolean + claudeAuth?: ClaudeRuntimeAuthPreparation | null + wslDistro?: string | null +} + +const RESTORED_PANE: AdoptStablePaneResult = { + result: { id: 'pty-restored' }, + owner: { tabId: 'tab-1', leafId: 'leaf-1', ptyId: 'pty-restored' } +} + +function notReachedByOptionBuilding(): never { + throw new Error('spawn option building must not call this dependency') +} + +function makeDeps(): PtySpawnIpcDeps & PtyRuntimeControllerDeps { + return { + getSettings: () => ({ ...getDefaultSettings('/tmp'), agentWorkspaceTrustEnabled: true }), + getLocalPtyStartupPromise: notReachedByOptionBuilding, + getLocalPtyProviderStartupPromise: notReachedByOptionBuilding, + adoptStablePane: notReachedByOptionBuilding, + assertFolderWorkspacePtyPathUsable: notReachedByOptionBuilding, + resolvePtySpawnStartupCwd: notReachedByOptionBuilding, + localStartupCwdDirectoryExists: notReachedByOptionBuilding, + prepareCodexResumeHome: notReachedByOptionBuilding, + noCodexResumeLaunch: notReachedByOptionBuilding, + resolveCodexResumeLaunch: notReachedByOptionBuilding, + reconcileSharedRuntimeResumeHome: notReachedByOptionBuilding, + stripSequencedStartupResumeArgv: notReachedByOptionBuilding, + transitionSpawnHiddenRendererPtyDeliveryState: notReachedByOptionBuilding, + trustedTerminalHandleEnv: new Set(), + sendPtySpawnedToRenderer: notReachedByOptionBuilding, + syncPtyBackgroundedDelivery: notReachedByOptionBuilding, + stopReplacedPty: notReachedByOptionBuilding, + requestSerializedBuffer: notReachedByOptionBuilding, + shutdownProviderAndDetectExit: notReachedByOptionBuilding, + rememberSyntheticKillExit: notReachedByOptionBuilding, + rememberRetiredRejectedPty: notReachedByOptionBuilding, + sendPtyExitToRenderer: notReachedByOptionBuilding, + finishPtyShutdown: notReachedByOptionBuilding, + retiredRejectedPtyIds: new Map() + } +} + +/** What preflight and env assembly leave for the option builders; both spawn states share it. */ +function seed( + ctx: Pick< + PtyIpcSpawnState, + 'env' | 'cwd' | 'launchCommand' | 'claudeAuth' | 'expectedWslDistro' | 'preAdoptedStablePane' + >, + input: BuildInput +): void { + ctx.env = { CLAUDE_CONFIG_DIR: '/cfg' } + ctx.cwd = input.cwd + ctx.launchCommand = input.command + ctx.claudeAuth = input.claudeAuth ?? null + ctx.expectedWslDistro = input.wslDistro ?? null + ctx.preAdoptedStablePane = input.restored ? RESTORED_PANE : null +} + +async function build(route: 'renderer' | 'runtime', input: BuildInput) { + const args = { + cols: 80, + rows: 24, + worktreeId: input.worktreeId ?? 'repo-1::/repo/wt', + connectionId: input.connectionId, + launchAgent: input.launchAgent, + command: input.command + } + if (route === 'renderer') { + const ctx = createPtyIpcSpawnState(makeDeps(), args) + seed(ctx, input) + await buildPtyIpcSpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions + } + const ctx = createRuntimePtySpawnState(makeDeps(), args) + seed(ctx, input) + await buildRuntimePtySpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions +} + +beforeEach(() => { + applyAgentWorkspaceTrust.mockReset() + applyAgentWorkspaceTrust.mockResolvedValue({}) +}) + +describe.each(['renderer', 'runtime'] as const)('%s spawn builder agent trust', (route) => { + it('pre-trusts the workspace for a fresh agent launch with the final spawn context', async () => { + await build(route, { + launchAgent: 'codex', + command: 'codex', + wslDistro: 'Ubuntu' + }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/repo/wt', { + env: expect.objectContaining({ CLAUDE_CONFIG_DIR: '/cfg' }), + claudeAuth: null, + wslDistro: 'Ubuntu', + connectionId: null + }) + }) + + it('trusts Codex in a floating terminal at the resolved folder it starts in', async () => { + await build(route, { + worktreeId: 'global-floating-terminal', + cwd: '/Users/me', + launchAgent: 'codex', + command: 'codex' + }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/Users/me', expect.anything()) + }) + + it('holds the spawn until the trust write settles', async () => { + let settleTrust = (): void => {} + applyAgentWorkspaceTrust.mockReturnValueOnce( + new Promise((resolve) => { + settleTrust = () => resolve({}) + }) + ) + let built = false + const building = build(route, { launchAgent: 'claude', command: 'claude' }).then(() => { + built = true + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(built).toBe(false) + settleTrust() + await building + expect(built).toBe(true) + }) + + it('keys on the declared agent even when setup sequencing rewrote the command', async () => { + await build(route, { launchAgent: 'claude', command: 'sh /tmp/orca-setup-runner.sh' }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('claude', '/repo/wt', expect.anything()) + }) + + it('never re-runs trust for a restored pane or a spawn with no launch command', async () => { + await build(route, { launchAgent: 'claude', command: 'claude', restored: true }) + await build(route, { launchAgent: 'claude' }) + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + }) + + it('forwards the relay trust field on an SSH agent spawn', async () => { + applyAgentWorkspaceTrust.mockResolvedValueOnce({ + agentWorkspaceTrust: { workspacePath: '/repo/wt' } + }) + const options = await build(route, { + connectionId: 'ssh-1', + launchAgent: 'codex', + command: 'codex' + }) + expect(options.agentWorkspaceTrust).toEqual({ workspacePath: '/repo/wt' }) + }) +}) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 22fe84caba6..4e67441ea34 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -34,7 +34,6 @@ const { registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, - registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, @@ -102,7 +101,6 @@ const { registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), - registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), @@ -330,10 +328,6 @@ vi.mock('../agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) -vi.mock('../agent-trust', () => ({ - registerAgentTrustHandlers: registerAgentTrustHandlersMock -})) - vi.mock('../claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -400,8 +394,15 @@ vi.mock('../native-chat', () => ({ registerNativeChatHandlers: registerNativeChatHandlersMock })) +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../runtime/structured-agent-session-host-refusal' import { registerCoreHandlers } from './register-core-handlers' +let registeredAiVaultOptions: { + ensureStructuredSessionOwnership: () => Promise +} +let registeredRuntime: { ensureStructuredAgentSessionHost: ReturnType } | undefined + describe('registerCoreHandlers', () => { beforeEach(() => { getPathMock.mockReset() @@ -438,7 +439,6 @@ describe('registerCoreHandlers', () => { registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() - registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() @@ -471,7 +471,11 @@ describe('registerCoreHandlers', () => { it('passes the store through to handler registrars that need it', async () => { const store = { marker: 'store' } - const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null } + const runtime = { + marker: 'runtime', + getAgentBrowserBridge: () => null, + ensureStructuredAgentSessionHost: vi.fn() + } const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } @@ -507,6 +511,9 @@ describe('registerCoreHandlers', () => { const aiVaultOptions = registerAiVaultHandlersMock.mock.calls[0]?.[0] expect(aiVaultOptions).toBeDefined() + // Registration happens once per module: later tests reach these handlers through here. + registeredAiVaultOptions = aiVaultOptions + registeredRuntime = runtime callRuntimeEnvironmentMock.mockResolvedValueOnce({ ok: true, @@ -646,6 +653,31 @@ describe('registerCoreHandlers', () => { ) }) + // Session history and terminal resume are not chats: the refusal chats get leaves them no host + // to check, and any other host failure still fails them. + it('serves session history while chats are refused', async () => { + const aiVaultOptions = registeredAiVaultOptions + expect(aiVaultOptions).toBeDefined() + const refusal = agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + recordStructuredAgentSessionHostInstallRefusal(refusal) + try { + registeredRuntime?.ensureStructuredAgentSessionHost.mockRejectedValueOnce(refusal) + await expect(aiVaultOptions.ensureStructuredSessionOwnership()).resolves.toBeUndefined() + registeredRuntime?.ensureStructuredAgentSessionHost.mockRejectedValueOnce( + new Error('the record store would not open') + ) + await expect(aiVaultOptions.ensureStructuredSessionOwnership()).rejects.toThrow( + 'the record store would not open' + ) + } finally { + recordStructuredAgentSessionHostInstallRefusal(null) + } + }) + it('only registers IPC handlers once but always updates web contents id', () => { // The first test already called registerCoreHandlers, so the module-level // guard is now set. beforeEach reset all mocks, so call counts are 0. diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index e692cc9c5e8..d3a647cafd7 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -60,7 +60,6 @@ import { registerCodexAccountHandlers } from '../codex-accounts' import { registerAgentHookHandlers } from '../agent-hooks' import { registerCodexConfigSyncHandlers } from '../codex-config-sync' import { getPtyIdForPaneKey } from '../pty' -import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' @@ -93,6 +92,7 @@ import { scanRuntimeAiVaultSessions } from '../../ai-vault/runtime-session-scanner' import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../runtime/structured-agent-session-host-refusal' import type { PluginService } from '../../plugins/plugin-service' import type { PluginMarketplaceHandlerServices } from '../plugin-marketplaces' @@ -149,7 +149,6 @@ export function registerCoreHandlers( registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) - registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() @@ -225,7 +224,11 @@ export function registerCoreHandlers( callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params) }) registerAiVaultHandlers({ - ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), + // Session history and terminal resume are not chats; a refused host leaves nothing to check. + ensureStructuredSessionOwnership: () => + ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/ipc/registered-worktree-root-relist-policy.ts b/src/main/ipc/registered-worktree-root-relist-policy.ts new file mode 100644 index 00000000000..7707bcce983 --- /dev/null +++ b/src/main/ipc/registered-worktree-root-relist-policy.ts @@ -0,0 +1,26 @@ +/** + * Which owners an authorized-roots rebuild has to re-list. + * + * Split out of registered-worktree-roots-cache so the policy is testable on its own + * and the cache module stays within its line budget. + */ +/** The owner fields the policy reads; `undefined` means no owner record yet. */ +export type RelistCandidate = + | { dirty: boolean; listed: unknown; recovered: { size: number } } + | undefined + +/** + * `onlyDirty` is the ensure path, where a single-repo invalidation must not relist + * every repo. An explicit rebuild keeps re-listing everything, because callers use + * it to force a refresh. + * + * A clean owner still holding recovered roots is always re-listed: those roots are + * retired by comparing against a fresh listing, so skipping it would strand them + * as authorized. + */ +export function shouldRelistOwner(owner: RelistCandidate, onlyDirty: boolean): boolean { + if (!onlyDirty || owner === undefined) { + return true + } + return owner.dirty || owner.listed == null || owner.recovered.size > 0 +} diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index 30d89fce2dd..ba538356b9a 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -6,6 +6,7 @@ import { pruneCreatedWorktreeRoots } from './registered-worktree-root-probes' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' +import { shouldRelistOwner } from './registered-worktree-root-relist-policy' import { getLocalWorktreeRootOwners, resolveWorktreeRootOwner @@ -38,6 +39,12 @@ function advanceOwner(owner: RegisteredOwner): void { registeredWorktreeRootsRevisionByRepo.set(owner.repoId, owner.revision) } +function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner | undefined { + const repos = synchronizeOwners(store) + const key = resolveWorktreeRootOwner(repos, repo, currentOwners) + return key === undefined ? undefined : registeredOwners.get(key) +} + function synchronizeOwners(store: Store): Repo[] { const repos = store.getRepos() const owners = getLocalWorktreeRootOwners(repos) @@ -88,15 +95,22 @@ export function invalidateAuthorizedRootsCache(): void { registeredWorktreeRootsRevisionByRepo.clear() } -export async function rebuildAuthorizedRootsCache(store: Store): Promise { +/** `onlyDirty` is the ensure path; an explicit rebuild still re-lists every repo. */ +export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = false): Promise { synchronizeOwners(store) const generation = invalidationGeneration - const pending = [...currentOwners].map(([key, repo]) => ({ - key, - repo, - owner: registeredOwners.get(key), - revision: registeredOwners.get(key)?.revision - })) + const pending = [...currentOwners] + .map(([key, repo]) => ({ + key, + repo, + owner: registeredOwners.get(key), + revision: registeredOwners.get(key)?.revision + })) + // Why only dirty owners: `dirty` used to gate whether a rebuild ran, not what it + // listed, so a single-repo invalidation still spawned `git worktree list` for + // every registered repo. An owner with no listing yet (`listed === null`) is + // always included, so a first rebuild is unchanged. + .filter((entry) => shouldRelistOwner(entry.owner, onlyDirty)) const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo)) const results = pending.map((entry, index) => ({ ...entry, ...listings[index] })) const isCurrent = (entry: (typeof pending)[number]): boolean => @@ -128,14 +142,33 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { registeredWorktreeRootsDirty = [...registeredOwners.values()].some((owner) => owner.dirty) } +/** + * Dirties exactly one owner so the next ensure-path rebuild re-lists only that repo. + * Returns false when the repo has no known owner, leaving the decision to the caller. + * + * Deliberately leaves `baseRevision` and the per-repo revision map alone: that pair is + * the global side-effect-token fence, and bumping it would retire in-flight tokens for + * untouched repos. `advanceOwner` still records this repo's new revision. + */ +export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string): boolean { + const owner = resolveOwnerForRepo(store, repo) + if (!owner) { + return false + } + owner.listed = null + owner.dirty = true + advanceOwner(owner) + refreshRegisteredWorktreeRoots() + registeredWorktreeRootsDirty = true + return true +} + export function registerWorktreeRootsForRepo( store: Store, repo: Repo | string, worktreeRoots: string[] ): void { - const repos = synchronizeOwners(store) - const key = resolveWorktreeRootOwner(repos, repo, currentOwners) - const owner = key === undefined ? undefined : registeredOwners.get(key) + const owner = resolveOwnerForRepo(store, repo) if (!owner) { return } @@ -152,9 +185,7 @@ export function registerCreatedWorktreeRoot( repo: Repo | string, worktreeRoot: string ): void { - const repos = synchronizeOwners(store) - const key = resolveWorktreeRootOwner(repos, repo, currentOwners) - const owner = key === undefined ? undefined : registeredOwners.get(key) + const owner = resolveOwnerForRepo(store, repo) if (!owner) { return } @@ -189,7 +220,7 @@ export async function ensureAuthorizedRootsCache(store: Store): Promise { // Follow one superseded refresh; continuous catalog churn must not pin authorization forever. for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) { if (!registeredWorktreeRootsRefresh) { - registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store).finally(() => { + registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store, true).finally(() => { registeredWorktreeRootsRefresh = null }) } diff --git a/src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts b/src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts new file mode 100644 index 00000000000..993bbcb34ae --- /dev/null +++ b/src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts @@ -0,0 +1,105 @@ +import { resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), stat: vi.fn(), realpath: vi.fn() })) +vi.mock('node:fs', () => ({ realpathSync: (path: string) => path, statSync: mocks.stat })) +vi.mock('node:fs/promises', () => ({ stat: mocks.stat, realpath: mocks.realpath })) +vi.mock('../repo-worktrees', () => ({ + listRepoWorktreeGraph: mocks.graph, + isRepoRoot: vi.fn(() => false) +})) +vi.mock('./worktree-logic', () => ({ + computeWorkspaceRoot: vi.fn(), + getWorktreePathSettings: vi.fn() +})) +vi.mock('../project-runtime-git-options', () => ({ + getWorktreeMirrorDistroForRuntime: vi.fn(), + resolveLocalProjectRuntimesForRepos: vi.fn() +})) +import { + invalidateAuthorizedRootsCache, + rebuildAuthorizedRootsCache +} from './registered-worktree-roots-cache' +import { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation' + +const REPO_COUNT = 12 +const repos: Repo[] = Array.from({ length: REPO_COUNT }, (_, index) => ({ + id: `repo-${index}`, + path: resolve(`/scoped-invalidation-${index}`), + displayName: `repo-${index}`, + badgeColor: '#000', + addedAt: 0 +})) + +function fixture(): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the authorization cache only reads these four store methods; filesystem and graph boundaries are mocked. + return { + getRepos: () => repos, + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) + } as unknown as Store +} + +function listedRepoPaths(): string[] { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this file installs the listRepoWorktreeGraph mock, so every call's first argument is the Repo it was handed. + return mocks.graph.mock.calls.map((call) => (call[0] as Repo).path) +} + +describe('authorized-roots invalidation scope', () => { + let store: Store + + beforeEach(async () => { + mocks.graph.mockReset() + mocks.graph.mockImplementation((repo: Repo) => + Promise.resolve([{ path: resolve(repo.path, 'wt') }]) + ) + mocks.stat.mockReset() + mocks.stat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + store = fixture() + // Prime every owner so a later scoped invalidation has clean state to dirty. + invalidateAuthorizedRootsCache() + await rebuildAuthorizedRootsCache(store) + mocks.graph.mockClear() + }) + + /** + * The regression this pins: the global form dirties every owner, so one worktree + * mutation cost a `git worktree list` per registered repo — ~58 spawns and seconds + * of git wall-clock on a real fleet, to rediscover roots only one repo changed. + */ + it('relists only the mutated repo', async () => { + invalidateAuthorizedRootsCacheForRepo(store, repos[3]) + await rebuildAuthorizedRootsCache(store, true) + + expect(listedRepoPaths()).toEqual([repos[3].path]) + }) + + it('still relists every repo for a change of unknown scope', async () => { + invalidateAuthorizedRootsCache() + await rebuildAuthorizedRootsCache(store, true) + + expect(listedRepoPaths()).toHaveLength(REPO_COUNT) + }) + + it('re-lists everything for an explicit rebuild, which callers use to force a refresh', async () => { + await rebuildAuthorizedRootsCache(store) + + expect(listedRepoPaths()).toHaveLength(REPO_COUNT) + }) + + it('falls back to a global relist when the repo has no known owner', async () => { + // An unregistered repo must not silently skip invalidation and leave a stale + // allowlist, so the global form is the safe fallback. + invalidateAuthorizedRootsCacheForRepo(store, { + ...repos[0], + id: 'unregistered', + path: resolve('/scoped-invalidation-unregistered') + }) + await rebuildAuthorizedRootsCache(store, true) + + expect(listedRepoPaths()).toHaveLength(REPO_COUNT) + }) +}) diff --git a/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts b/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts new file mode 100644 index 00000000000..39efe7e9194 --- /dev/null +++ b/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts @@ -0,0 +1,29 @@ +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' +import { + invalidateAuthorizedRootsCache, + markAuthorizedRootsOwnerDirty +} from './registered-worktree-roots-cache' + +/** + * Scoped counterpart to `invalidateAuthorizedRootsCache` for a change whose blast + * radius is provably one repo — every worktree create and removal. + * + * Why this exists: the global form dirties every registered owner, so the next + * authorization-requiring IPC rebuilds by listing EVERY repo. At 58 repos that is 58 + * `git worktree list` spawns — roughly ten seconds of git wall-clock through an + * admission budget of four — to rediscover roots only one repo changed. + * + * Falls back to the global form rather than silently skipping an invalidation, which + * would leave a stale allowlist. Callers whose change can alter the owner SET rather + * than one repo's roots (store swap, execution-host/WSL re-routing, nested-repo + * import, folder->git upgrade) must keep using the global form. + */ +export function invalidateAuthorizedRootsCacheForRepo( + store: Store | null | undefined, + repo: Repo | string +): void { + if (!store || !markAuthorizedRootsOwnerDirty(store, repo)) { + invalidateAuthorizedRootsCache() + } +} diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 14b8bbd0b68..7f7c3a3976d 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -28,7 +28,8 @@ vi.mock('../git/repo', () => ({ })) vi.mock('./registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../providers/ssh-git-dispatch', () => ({ diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index af8bf4ecf28..adf2cc2f17b 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -35,7 +35,10 @@ vi.mock('../git/repo', () => ({ filterBaseRefSearchOutput: vi.fn().mockReturnValue([]) })) -vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) +vi.mock('./registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() +})) vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 5f7e7202e73..31843858c05 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -43,7 +43,8 @@ vi.mock('../git/repo', () => ({ })) vi.mock('./registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../providers/ssh-git-dispatch', () => ({ diff --git a/src/main/ipc/repos/repo-creation-git-availability.test.ts b/src/main/ipc/repos/repo-creation-git-availability.test.ts index 8ffbbdc033e..fb112952470 100644 --- a/src/main/ipc/repos/repo-creation-git-availability.test.ts +++ b/src/main/ipc/repos/repo-creation-git-availability.test.ts @@ -15,7 +15,8 @@ vi.mock('../../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: vi.fn(async () => {}) })) vi.mock('../registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('./repo-added-telemetry', () => ({ emitRepoAdded: vi.fn() })) vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() })) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index f3c1b8aeaf8..d2a79ef1885 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -12,6 +12,7 @@ import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../share import type { AgentAwakeService } from '../agent-awake-service' import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { applyElectronProxySettings } from '../network/proxy-settings' import { applyBrowserSessionProxies } from '../browser/browser-session-proxy' @@ -242,13 +243,7 @@ export function registerSettingsHandlers( userInitiated: true, shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = store.getSettings() - return ( - settings.agentStatusHooksEnabled !== false && - !settings.disabledTuiAgents.includes(agent) - ) - } + shouldContinue: (agent) => isAgentStatusHooksEnabledForAgent(store.getSettings(), agent) }) } catch (error) { console.warn('[settings] failed to reconcile managed agent hooks:', error) diff --git a/src/main/ipc/terminal-startup-color-query-replies.test.ts b/src/main/ipc/terminal-startup-color-query-replies.test.ts index 7a2fd80ef69..b8a0b011686 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.test.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.test.ts @@ -13,15 +13,27 @@ it.each([{ launchAgent: 'omp' }, { command: 'omp' }, { telemetry: { agent_kind: }) } ) -it('leaves ordinary shells and unadvertised keyboard support alone', () => { +it('keeps Kitty startup support to agent launches', () => { expect( getStartupTerminalIngressIntent({ command: 'echo hello', terminalKittyKeyboardProtocol: true }) ).toBeUndefined() expect(getStartupTerminalIngressIntent({ launchAgent: 'omp' })).toBeUndefined() }) -it('preserves color-only startup on renderers without Kitty support', () => { +it('seeds the creating viewer colours for every PTY, not only agent launches', () => { const colors = { foreground: '#fff', background: '#000' } expect( getStartupTerminalIngressIntent({ launchAgent: 'omp', terminalColorQueryReplies: colors }) ).toEqual({ colors, deadlineMs: 5000 }) + expect( + getStartupTerminalIngressIntent({ + command: 'echo hello', + terminalColorQueryReplies: colors, + terminalKittyKeyboardProtocol: true + }) + ).toEqual({ colors, deadlineMs: 5000 }) + expect( + getStartupTerminalIngressIntent({ + terminalColorQueryReplies: { foreground: '#fff', background: 'not-a-color' } + }) + ).toBeUndefined() }) diff --git a/src/main/ipc/terminal-startup-color-query-replies.ts b/src/main/ipc/terminal-startup-color-query-replies.ts index 6793423285f..6f02ab299af 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.ts @@ -1,31 +1,11 @@ import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress-intent' +import { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { isTuiAgent } from '../../shared/tui-agent-config' import { agentKindSchema } from '../../shared/telemetry-events' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' -import { - terminalOscColorQueryReply, - type TerminalOscColorQueryReplyColors -} from '../../shared/terminal-osc-color-reply' -function normalizeTerminalColorQueryReplyColors( - value: unknown -): TerminalOscColorQueryReplyColors | null { - if (!value || typeof value !== 'object') { - return null - } - const record = value as { foreground?: unknown; background?: unknown } - const colors = { - ...(typeof record.foreground === 'string' ? { foreground: record.foreground } : {}), - ...(typeof record.background === 'string' ? { background: record.background } : {}) - } - if (!terminalOscColorQueryReply(colors, 10) || !terminalOscColorQueryReply(colors, 11)) { - return null - } - return colors -} - -function shouldReplyToStartupTerminalColorQueries(args: { +function isAgentLaunch(args: { launchAgent?: unknown telemetry?: { agent_kind?: unknown } | undefined command?: string @@ -53,12 +33,11 @@ export function getStartupTerminalIngressIntent(args: { terminalColorQueryReplies?: unknown terminalKittyKeyboardProtocol?: boolean }) { - if (!shouldReplyToStartupTerminalColorQueries(args)) { - return undefined - } + // Why colours for every PTY: an agent typed into a plain shell later queries too, and these + // seed an owner that has not been pushed the host's viewer colours yet. return parsePtyStartupIngressIntent({ - colors: normalizeTerminalColorQueryReplyColors(args.terminalColorQueryReplies) ?? {}, - kittyKeyboardProtocol: args.terminalKittyKeyboardProtocol, + colors: normalizeColorQueryReplyColors(args.terminalColorQueryReplies) ?? {}, + kittyKeyboardProtocol: args.terminalKittyKeyboardProtocol === true && isAgentLaunch(args), deadlineMs: 5_000 }) } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 767c6ece246..ce11df4f4f0 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1,4 +1,3 @@ -import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' /* eslint-disable max-lines */ // Why: worktree create helpers (local + remote) split out of worktrees.ts; the cohesive create flow runs this file just over the per-file line limit. @@ -75,7 +74,7 @@ import { import { requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' -import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' +import { isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' import { @@ -153,12 +152,6 @@ import { import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' -import { - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' -import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -437,29 +430,6 @@ async function spawnLocalStartupAndSetupTerminals(args: { try { // Why: only after `git worktree add` + metadata registration is the path safe for a runtime PTY to boot the agent while setup runs alongside. - if (isTuiAgent(createdWithAgent)) { - const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust - try { - if (preset === 'qoder') { - markQoderWorkspaceTrusted(worktree.path) - } else if (preset === 'cursor') { - markCursorWorkspaceTrusted(worktree.path) - } else if (preset === 'copilot') { - markCopilotFolderTrusted(worktree.path) - } else if (preset === 'codex') { - // Why: the PTY below spawns Codex immediately; a discarded Promise let - // it reach the trust menu before the write landed, and its rejection - // escaped this synchronous catch. Bounded so a wedged config lane - // cannot stall worktree creation. - await awaitAgentTrustWriteWithinDeadline(markCodexProjectTrusted(worktree.path), { - preset, - workspacePath: worktree.path - }) - } - } catch { - // Best-effort: launch still proceeds and the agent can ask interactively. - } - } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts index 9d88143d3c4..6f4a95d5d42 100644 --- a/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts @@ -7,7 +7,8 @@ vi.mock('../../worktree-remote', () => ({ notifyWorktreesChanged: vi.fn() })) vi.mock('../../registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('./worktree-removal-ownership', () => ({ removeWorktreeMetadataAndTransientState: vi.fn(), diff --git a/src/main/native-chat/agent-session-journal/journal-close-retry.ts b/src/main/native-chat/agent-session-journal/journal-close-retry.ts deleted file mode 100644 index e73be0a59b8..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-close-retry.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Where a journal goes when its close REJECTS. -// -// `AgentSessionJournal.close()` is deliberately retryable: a rejection does not -// release the handle, and a second call is a real second attempt. Every caller -// that did `close().catch(() => undefined)` and then threw or overwrote its map -// entry defeated that contract — the store became unreachable with its SQLite -// connection still open. On POSIX that is a silent leak; on Windows the open -// handle blocks renaming or removing the journal directory outright. -// -// So a rejected close hands the journal here instead of dropping it, and host -// teardown retries everything this holds. Retention is bounded by construction: -// an entry leaves the set the moment its close fulfils, and a journal can be -// retained only once because the set is keyed by identity. - -/** Everything the registry needs; `AgentSessionJournal` satisfies it. */ -export type RetryableJournalClose = { - close: () => Promise - readonly directory: string -} - -export class JournalCloseRetryRegistry { - private readonly retained = new Set() - - /** Directories still held by a journal whose close has not fulfilled. */ - get pendingDirectories(): string[] { - return [...this.retained].map((journal) => journal.directory) - } - - /** - * Close it. Returns true when the handle is actually released; on a rejection - * the journal is RETAINED for `retryAll` and the rejection is returned rather - * than thrown, because every caller of this is already unwinding a different - * failure it must not lose. - */ - async closeOrRetain( - journal: RetryableJournalClose - ): Promise<{ closed: boolean; error?: unknown }> { - try { - await journal.close() - this.retained.delete(journal) - return { closed: true } - } catch (error) { - this.retained.add(journal) - return { closed: false, error } - } - } - - /** Retry every retained close. Ones that fulfil are dropped; ones that reject - * stay retained and their rejections are returned for the caller to report. */ - async retryAll(): Promise { - const entries = [...this.retained] - const failures: unknown[] = [] - for (const journal of entries) { - const result = await this.closeOrRetain(journal) - if (!result.closed) { - failures.push(result.error) - } - } - return failures - } -} - -/** - * Process-wide, because ownership of these handles is process-wide: the attach - * path, the recovery wrapper and runtime teardown are separate call trees that - * must all be able to reach the same orphan. - */ -export const agentSessionJournalCloseRetries = new JournalCloseRetryRegistry() diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index 978f076d6e9..a5e7506d1bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A repair drops what it cannot replay, and says so. // // Two things make a suffix unreplayable: a row this build cannot parse, and a @@ -17,12 +18,16 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' import { parseJournalRow, type JournalRow } from './journal-row-schema' -import { loadJournal } from './journal-open' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + liveTestJournalRows, + updateTestJournalRowJson, + deleteTestJournalRow +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -52,7 +57,7 @@ function body(value: string): AgentJournalItemBody { function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -60,11 +65,11 @@ function open(overrides: Partial[0]> } async function withJournalDatabase(run: (db: Database.Database) => void): Promise { - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { run(opened.db) } finally { - opened.db.close() + opened.close() } } @@ -72,10 +77,8 @@ async function withJournalDatabase(run: (db: Database.Database) => void): Promis function firstLiveRow(): Promise { let row: JournalRow | null = null return withJournalDatabase((db) => { - const stored = db.prepare('SELECT row_json FROM journal_rows ORDER BY seq LIMIT 1').get() as - | { row_json: string } - | undefined - const parsed = stored ? parseJournalRow(stored.row_json) : null + const stored = liveTestJournalRows(db, IDENTITY.sessionId)[0] + const parsed = stored ? parseJournalRow(stored.rowJson) : null row = parsed?.ok ? parsed.row : null }).then(() => row) } @@ -83,9 +86,7 @@ function firstLiveRow(): Promise { function liveSequences(): Promise { let sequences: number[] = [] return withJournalDatabase((db) => { - sequences = ( - db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() as { seq: number }[] - ).map((row) => row.seq) + sequences = liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.seq) }).then(() => sequences) } @@ -102,12 +103,21 @@ afterEach(async () => { describe('a malformed row', () => { it('keeps the readable prefix live and drops the rest of the epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('unreadable'), { fence: 1 }) - await journal.appendItem(item(2), body('after the fault'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('unreadable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(2), body('after the fault'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3) + updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '{"not":"a row"}') }) const reopened = await open() @@ -118,11 +128,17 @@ describe('a malformed row', () => { it('discloses the line it could not read', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('later'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) + updateTestJournalRowJson(db, IDENTITY.sessionId, 2, '}{') }) const reopened = await open() @@ -141,13 +157,16 @@ describe('a sequence gap', () => { it('drops every row after the hole and reports the epoch corrupt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves // 5 and 6 valid but unanchored. await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4) + deleteTestJournalRow(db, IDENTITY.sessionId, 4) }) const reopened = await open() @@ -163,23 +182,29 @@ describe('a sequence gap', () => { it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4) + deleteTestJournalRow(db, IDENTITY.sessionId, 4) }) const repaired = await open() await repaired.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) // Same policy the emptied-epoch repair takes: a session that writes into the // epoch owns it, and a later import must not replace rows the user has seen. const writable = await open() - await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(9), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) // The disclosure is the repair talking about itself, not the session writing: @@ -187,17 +212,20 @@ describe('a sequence gap', () => { it('is not settled by the repair disclosure it appends for a malformed row', async () => { const journal = await open() for (let ordinal = 0; ordinal < 3; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 3) + updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') }) const repaired = await open() expect(repaired.repair.malformedRows).toBe(1) await repaired.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) }) }) @@ -207,7 +235,10 @@ describe('a missing epoch row', () => { // renders a repaired journal as a clean timeline. it('rejects the whole surviving range rather than declaring it contiguous', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -226,7 +257,7 @@ describe('a missing epoch row', () => { }) await journal.close() await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(db, IDENTITY.sessionId, 1) }) const reopened = await open() @@ -245,21 +276,27 @@ describe('a missing epoch row', () => { // consulted again and the dropped rows never come back. it('keeps asking for provider history until the epoch has content of its own', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(db, IDENTITY.sessionId, 1) }) const repaired = await open() await repaired.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) // A session that writes into the epoch owns it: its own rows are not a // repair placeholder, and a later import must not replace them. const writable = await open() - await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(1), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 6cc267a9054..a6821cf8427 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The crash boundary: the host wrote a submission row, dispatched, and died // before it learned whether the provider took the message. Replay must reconcile // without duplicating the user's message and without losing it. @@ -24,7 +25,7 @@ import { type ProviderHistoryItem, type ProviderHistoryWindow } from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -62,7 +63,7 @@ const journals = createTrackedJournalOpener() async function open() { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) @@ -138,7 +139,10 @@ describe('crash between provider accept and journal commit', () => { }) // The provider's own copy of the message arrives next, under the identity // reconciliation adopted. It must land in the bubble the user already sees. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { + fence: 2, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const items = restarted.snapshot().items expect(items).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index 37675fbcd8e..ffad529cb4b 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -1,16 +1,17 @@ -// Table shape for one session's journal database. +// Table shape for the host's one chat journal database. // -// `journal_rows` is the append-only log; `journal_sessions` is the derived -// projection, upserted in the SAME transaction as every row insert so the live -// epoch and the rows that belong to it can never disagree. `journal_repairs` -// carries at most one row per session: the standing demand for a rebuild a -// partial repair leaves behind (see journal-repair-marker.ts). +// `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. +// `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so +// an append is one INSERT. `journal_repairs` carries at most one row per chat: the standing demand +// for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports` +// records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and +// `journal_set_aside` each chat whose per-chat file is not this build's history and is never read +// again. -/** DB shape version, carried in `PRAGMA user_version`. Independent of the row - * body version (`JournalRow.v`): a newer build can change either alone. - * v2 added `journal_repairs`; a build without it would replay a partially - * repaired journal as clean, so it must latch read-only rather than write. */ -export const JOURNAL_DB_SCHEMA_VERSION = 2 +/** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version + * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, + * so every change stays additive. Versions below 3 were never released (journal-database.ts). */ +export const JOURNAL_DB_SCHEMA_VERSION = 3 export function createJournalTablesSql(): string { return ` @@ -23,9 +24,9 @@ CREATE TABLE IF NOT EXISTS journal_rows ( PRIMARY KEY (session_id, epoch, seq) ); CREATE TABLE IF NOT EXISTS journal_sessions ( - session_id TEXT PRIMARY KEY, - epoch TEXT NOT NULL, - updated_at INTEGER NOT NULL + session_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + epoch TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS journal_repairs ( session_id TEXT PRIMARY KEY, @@ -33,5 +34,15 @@ CREATE TABLE IF NOT EXISTS journal_repairs ( content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS journal_imports ( + session_id TEXT PRIMARY KEY, + epoch TEXT NOT NULL, + tip INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS journal_set_aside ( + session_id TEXT PRIMARY KEY, + epoch TEXT NOT NULL, + tip INTEGER NOT NULL +); ` } diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 358c42c8d55..0b479cd9adf 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -1,23 +1,27 @@ -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Database from '../../sqlite/sync-database' import { JOURNAL_BUSY_TIMEOUT_MS, + JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabaseFile } from './journal-paths' +import { journalDatabasePath } from './journal-host-database' +import { JournalUnreleasedSchemaError } from './journal-open-failure' import { - deleteAllJournalRows, + deleteJournalEpochRows, deleteJournalRowSuffix, + deleteUnpublishedJournalRows, insertJournalRow, - readJournalEpochRows, + iterateJournalEpochRows, + publishJournalSessionEpoch, readJournalRowsAfter, - readJournalSessionEpoch, - upsertJournalSessionRow + readJournalSessionEpoch } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' @@ -38,9 +42,21 @@ function epochRow(seq: number, epoch = 'epoch-1'): JournalRow { } } +const SESSION = { sessionId: 'session-1', workspaceId: 'ws-1' } + +function rowsOf(db: Database.Database, sessionId: string, epoch: string): number[] { + return [...iterateJournalEpochRows(db, sessionId, epoch)].map((row) => row.seq) +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) - dbPath = journalDatabaseFile(root) + dbPath = journalDatabasePath(root) }) afterEach(async () => { @@ -48,53 +64,63 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('journal database open', () => { - it('creates both tables and reads back every load-bearing pragma', () => { - const opened = openJournalDatabase(dbPath) +describe('the host journal database open', () => { + it('creates every table and reads back every load-bearing pragma', () => { + const db = openJournalDatabase(dbPath).db try { - const tables = opened.db + const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") .all() .map((entry) => (entry as { name: string }).name) - expect(tables).toContain('journal_rows') - expect(tables).toContain('journal_sessions') - expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(journalPragmaNumber(opened.db, 'synchronous')).toBe(2) - expect(journalPragmaNumber(opened.db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS) - expect(journalPragmaNumber(opened.db, 'foreign_keys')).toBe(1) - expect(journalPragmaNumber(opened.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) - expect(opened.readOnly).toBe(false) + expect(tables).toEqual( + expect.arrayContaining([ + 'journal_imports', + 'journal_repairs', + 'journal_rows', + 'journal_sessions', + 'journal_set_aside' + ]) + ) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(journalPragmaNumber(db, 'synchronous')).toBe(2) + expect(journalPragmaNumber(db, 'checkpoint_fullfsync')).toBe(1) + expect(journalPragmaNumber(db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS) + expect(journalPragmaNumber(db, 'foreign_keys')).toBe(1) + // 2 is INCREMENTAL, which only takes on an empty file before WAL. + expect(journalPragmaNumber(db, 'auto_vacuum')).toBe(2) + expect(journalPragmaNumber(db, 'journal_size_limit')).toBe(JOURNAL_SIZE_LIMIT_BYTES) + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + } finally { + db.close() + } + }) + + // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. + it('opens a newer user_version read-only without touching the file', async () => { + const seeded = openJournalDatabase(dbPath).db + publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') + insertJournalRow(seeded, 'session-1', epochRow(1)) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) + seeded.close() + const before = await digest(dbPath) + + const opened = openJournalDatabase(dbPath) + try { + expect(opened.readOnly).toBe(true) + expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') + expect([...iterateJournalEpochRows(opened.db, 'session-1', 'epoch-1')]).toHaveLength(1) + expect(() => insertJournalRow(opened.db, 'session-1', epochRow(2))).toThrow(/readonly/i) } finally { opened.db.close() } + + expect(await digest(dbPath)).toBe(before) + // A read-only reader of a WAL file may leave an empty log beside it, never a frame. + expect((await stat(`${dbPath}-wal`).catch(() => null))?.size ?? 0).toBe(0) }) - it('latches read-only on a future user_version without touching the file', async () => { - const seeded = openJournalDatabase(dbPath) - upsertJournalSessionRow(seeded.db, 'session-1', 'epoch-1', 1) - insertJournalRow(seeded.db, 'session-1', epochRow(1)) - seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) - seeded.db.close() - const before = await stat(dbPath) - - const latched = openJournalDatabase(dbPath) - try { - expect(latched.readOnly).toBe(true) - expect(journalPragmaNumber(latched.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION + 5) - expect(readJournalEpochRows(latched.db, 'session-1', 'epoch-1')).toHaveLength(1) - expect(() => latched.db.exec("INSERT INTO journal_sessions VALUES ('x', 'y', 1)")).toThrow() - } finally { - latched.db.close() - } - expect((await stat(dbPath)).size).toBe(before.size) - expect(journalPragmaNumber(openJournalDatabase(dbPath).db, 'user_version')).toBe( - JOURNAL_DB_SCHEMA_VERSION + 5 - ) - }) - - // Site 1: the raw connection is owned by the open call until it returns. it('closes the raw connection when schema setup throws', async () => { - const failing = join(root, 'nested', 'journal.db') + const failing = join(root, 'nested', 'agent-session-journal.db') expect(() => openJournalDatabase(failing)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() @@ -103,73 +129,84 @@ describe('journal database open', () => { }) describe('journal row statements', () => { - it('serves replay, resume, discard and suffix truncation from the primary key', () => { - const opened = openJournalDatabase(dbPath) + it('serves replay, resume, suffix truncation and an epoch discard', () => { + const db = openJournalDatabase(dbPath).db try { - const { db } = opened db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { insertJournalRow(db, 'session-1', epochRow(seq)) } - insertJournalRow(db, 'session-1', epochRow(1, 'epoch-old')) - upsertJournalSessionRow(db, 'session-1', 'epoch-1', 42) + insertJournalRow(db, 'session-2', epochRow(1)) + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-1') db.exec('COMMIT') expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') expect(readJournalSessionEpoch(db, 'absent')).toBeNull() - expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([ - 1, 2, 3, 4, 5 - ]) + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3, 4, 5]) expect(readJournalRowsAfter(db, 'session-1', 'epoch-1', 3).map((row) => row.seq)).toEqual([ 4, 5 ]) - // The rejected suffix leaves `journal_rows`, scoped to its own epoch. expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2) - expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([ - 1, 2, 3 - ]) - expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(1) + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3]) - deleteAllJournalRows(db) - expect(readJournalEpochRows(db, 'session-1', 'epoch-1')).toHaveLength(0) - expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(0) - expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') + // Another chat in the same file is untouched by this chat's discard. + deleteJournalEpochRows(db, 'session-1', 'epoch-1') + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-1')).toEqual([1]) } finally { - opened.db.close() + db.close() } }) - it('refuses a duplicate sequence inside one epoch', () => { - const opened = openJournalDatabase(dbPath) + it('deletes only the rows no pointer names', () => { + const db = openJournalDatabase(dbPath).db try { - insertJournalRow(opened.db, 'session-1', epochRow(1)) - expect(() => insertJournalRow(opened.db, 'session-1', epochRow(1))).toThrow() - insertJournalRow(opened.db, 'session-1', epochRow(1, 'epoch-2')) + insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) + insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) + insertJournalRow(db, 'session-2', epochRow(1, 'epoch-stale')) + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-live') + + deleteUnpublishedJournalRows(db, 'session-1') + deleteUnpublishedJournalRows(db, 'session-2') + + expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1]) + expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([]) } finally { - opened.db.close() + db.close() } }) - it('upserts the session projection in place', () => { - const opened = openJournalDatabase(dbPath) + it('refuses a duplicate sequence inside one epoch of one chat', () => { + const db = openJournalDatabase(dbPath).db try { - upsertJournalSessionRow(opened.db, 'session-1', 'epoch-1', 1) - upsertJournalSessionRow(opened.db, 'session-1', 'epoch-2', 2) - expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-2') - expect( - opened.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() - ).toMatchObject({ total: 1 }) + insertJournalRow(db, 'session-1', epochRow(1)) + expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() + insertJournalRow(db, 'session-1', epochRow(1, 'epoch-2')) + insertJournalRow(db, 'session-2', epochRow(1)) } finally { - opened.db.close() + db.close() + } + }) + + it('moves the epoch pointer in place', () => { + const db = openJournalDatabase(dbPath).db + try { + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, SESSION, 'epoch-2') + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') + expect(db.prepare('SELECT count(*) AS total FROM journal_sessions').get()).toMatchObject({ + total: 1 + }) + } finally { + db.close() } }) }) describe('schema creation', () => { - // Creating the tables outside the migration transaction left a v2-shaped - // database still reporting version 0, which an older build does not latch - // read-only: it stamps its own version on and writes through v1 SQL. it('publishes no table until the version bump commits with it', () => { const original = Database.prototype.pragma const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( @@ -198,4 +235,25 @@ describe('schema creation', () => { inspected.close() } }) + + // Versions 1 and 2 were written only by unreleased builds; neither is migrated. + it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { + const earlier = new Database(dbPath) + earlier.pragma('journal_mode = WAL') + earlier.exec(` +CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, row_json TEXT NOT NULL); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + epoch TEXT NOT NULL, block INTEGER NOT NULL UNIQUE, status_json TEXT, status_seq INTEGER); +INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) + earlier.pragma(`user_version = ${version}`) + earlier.pragma('journal_mode = DELETE') + earlier.close() + const before = await digest(dbPath) + + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) + + expect(await digest(dbPath)).toBe(before) + await expect(stat(`${dbPath}-wal`)).rejects.toThrow() + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 6f1cd60733b..2c01a653af3 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -1,14 +1,20 @@ -// Opening one session's journal database. +// Opening the host's chat journal database. // -// `PRAGMA user_version` is read FIRST, on a connection that has set no -// persistent pragma and run no DDL: a future-schema database must be left -// byte-identical, and `journal_mode = WAL` writes the file header. +// `PRAGMA user_version` is read FIRST, on a connection that has set no persistent pragma and run +// no DDL: a database written by a newer schema must be left byte-identical, and +// `journal_mode = WAL` writes the file header. import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { JournalUnreleasedSchemaError } from './journal-open-failure' +import { ensureQueuedMessagesTable } from './queued-message-schema' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 +/** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ +export const JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024 +/** Every commit but a first-use copy's batches, which no reader follows until a synced commit. */ +export const JOURNAL_SYNCHRONOUS = 'FULL' export type OpenJournalDatabase = { db: Database.Database @@ -35,12 +41,21 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } let transferred = false try { - configureJournalPragmas(probe) + if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { + // No retry reads past it, so every chat says it can't load; the log says what to do. + throw new JournalUnreleasedSchemaError( + `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca; move the file aside and Orca starts a new one` + ) + } + configureJournalPragmas(probe, stored) createJournalSchema(probe, stored) + // Outside `createJournalSchema` on purpose: its early return skips a db + // already at the current version, and this table must exist at EVERY + // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). + ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) - const opened = { db: probe, readOnly: false } transferred = true - return opened + return { db: probe, readOnly: false } } finally { if (!transferred) { probe.close() @@ -48,33 +63,70 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } } -function configureJournalPragmas(db: Database.Database): void { +function configureJournalPragmas(db: Database.Database, stored: number): void { + if (stored === 0) { + // Only takes on an empty file, and only before WAL, so it is set now: nothing reclaims pages + // yet, but a later pass can free them in bounded steps rather than a full VACUUM. + db.pragma('auto_vacuum = INCREMENTAL') + } db.pragma('journal_mode = WAL') db.pragma(`busy_timeout = ${JOURNAL_BUSY_TIMEOUT_MS}`) db.pragma('foreign_keys = ON') - // Why FULL rather than the house NORMAL: the write-ahead submission row must - // survive a power loss before the adapter dispatches anything, and NORMAL in - // WAL mode does not fsync at commit. - db.pragma('synchronous = FULL') + // Why FULL rather than the house NORMAL: NORMAL in WAL mode does not fsync at commit, and the + // write-ahead submission row must be on disk before the adapter dispatches anything. FULL alone + // does not survive power loss on macOS, whose fsync leaves the drive cache unflushed; checkpoint + // fullfsync makes each checkpoint use F_FULLFSYNC (a no-op elsewhere). + db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + db.pragma('checkpoint_fullfsync = ON') + db.pragma(`journal_size_limit = ${JOURNAL_SIZE_LIMIT_BYTES}`) } /** - * Table creation and the `user_version` bump are ONE transaction. Creating the - * tables first left a shaped database still reporting version 0, which an older - * build does not latch read-only: it stamped its own version on and wrote - * through SQL for a schema it did not have. + * Table creation and the `user_version` bump are ONE transaction. Creating the tables first left + * a shaped database still reporting version 0, which an older build does not latch read-only. */ function createJournalSchema(db: Database.Database, stored: number): void { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { return } - db.exec('BEGIN IMMEDIATE') - try { + runJournalTransaction(db, () => { db.exec(createJournalTablesSql()) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) + }) +} + +/** + * One IMMEDIATE transaction, its COMMIT inside the failure boundary: SQLite can leave a transaction + * open after a failed COMMIT, and on the shared connection every later BEGIN would then fail. The + * caller gets the original error; a ROLLBACK that fails too goes to `onStranded`. `run` is + * synchronous by contract: an await inside it would let another chat's statements land in this + * transaction. + */ +export function runJournalTransaction( + db: Database.Database, + run: (db: Database.Database) => T, + onStranded: () => void = () => undefined +): T { + db.exec('BEGIN IMMEDIATE') + try { + const result = run(db) + if (result instanceof Promise) { + throw new Error('a chat journal transaction must not await') + } db.exec('COMMIT') + return result } catch (error) { - db.exec('ROLLBACK') + if (db.isTransaction) { + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + console.warn( + '[agent-session-journal] rolling back a failed transaction failed', + rollbackError + ) + onStranded() + } + } throw error } } diff --git a/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts new file mode 100644 index 00000000000..0fbadede3bc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts @@ -0,0 +1,51 @@ +// The turn scope of a row written before rows stated one: the root turn that was open when the +// row was created. Hosts now state a scope on every item they write, so this reads only legacy +// rows, and rows an older host writes into a newer journal after a downgrade. It needs no +// persisted state: the journal is replayed in full on every open, in creation order. +// +// A root turn record created running opens a span, which closes when a revision ends it or +// removes its turn body — a legacy `/compact` row was created as a running turn carrier and then +// overwritten with plain status. A turn record created already settled (rebuilt history) stays +// open until the next root turn record: that is its position within the provider's history. +// Rows created in the window between a crash and the stale-turn sweep land in the dead turn, +// which is where their position puts them too. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' + +export class JournalDerivedTurnScope { + private openTurnItemId: string | null = null + + /** The scope for a row being created now that states none. A turn record belongs to no turn. */ + scopeFor(body: AgentJournalItemBody): AgentJournalTurnScope { + return this.openTurnItemId === null || readAgentJournalTurn(body) + ? AGENT_JOURNAL_THREAD_SCOPE + : { kind: 'turn', turnItemId: this.openTurnItemId } + } + + /** Every change to an item, from any row, after it is applied. `before` is undefined when the + * row created the item, `after` when it removed it. Only the session's own turns open a span. */ + observe( + itemId: string, + root: boolean, + before: AgentJournalItemBody | undefined, + after: AgentJournalItemBody | undefined + ): void { + const turn = readAgentJournalTurn(after) + if (before === undefined && turn && root) { + this.openTurnItemId = itemId + return + } + if ( + itemId === this.openTurnItemId && + readAgentJournalTurn(before)?.state === 'running' && + turn?.state !== 'running' + ) { + this.openTurnItemId = null + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index 668232e607d..cefd4bbe368 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -10,7 +10,7 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -30,7 +30,7 @@ const journals = createTrackedJournalOpener() async function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => (clock += 1), mintEpoch: () => `epoch-${clock}`, ...overrides diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 470ed3dfcb2..89722959799 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -6,7 +6,9 @@ import { type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' +import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' export function applyJournalDispatchRow( @@ -14,23 +16,15 @@ export function applyJournalDispatchRow( row: Extract ): void { const submission = state.submissions.get(row.clientMessageId) - if (!submission) { - return - } - // `rejected` is terminal; a late `unknown` must not reopen a settled answer. - if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { + // Shared with the queued-draft returned hook: a row ignored here must not alter a draft. + if (!submission || !journalDispatchRowApplies(submission)) { return } submission.fence = row.fence submission.dispatchState = row.state submission.providerItemId = row.providerItemId submission.reason = row.reason - // Read where it can be placed; a kind it cannot place is kept as written, so the classifier - // still knows a fact was there without this build claiming what it says. - const rejection = - row.state === 'rejected' - ? (readAgentSessionFailureFact(row.rejection) ?? unreadFailureFact(row.rejection)) - : undefined + const rejection = row.state === 'rejected' ? readStoredRejectionFact(row.rejection) : undefined if (rejection) { submission.rejection = rejection } else { @@ -39,12 +33,16 @@ export function applyJournalDispatchRow( submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts + placeHandedOverMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered } else { delete submission.recovered } + if (row.state === 'accepted') { + notePersonTurnAccepted(state, submission) + } if (row.state !== 'accepted' || !row.providerItemId) { return } @@ -57,6 +55,13 @@ export function applyJournalDispatchRow( }) } +/** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as + * written, so the classifier still knows a fact was there without this build claiming what it + * says. Shared with the queued-draft table, whose returned card mirrors its submission. */ +export function readStoredRejectionFact(value: unknown): UnreadAgentSessionFailureFact | undefined { + return readAgentSessionFailureFact(value) ?? unreadFailureFact(value) +} + function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined { return typeof value === 'object' && value !== null && diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts new file mode 100644 index 00000000000..7f9711151fc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionFailureFact, + type SubmissionRejectionKind +} from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { rejectedDraftSettlement } from './journal-dispatch-settlement' + +function settle(kind: SubmissionRejectionKind) { + return rejectedDraftSettlement( + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) + ) +} + +describe('what a rejection does to the draft it was consumed from', () => { + it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { + expect(settle('cancelled')).toEqual({ state: 'waiting' }) + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ + state: 'waiting' + }) + }) + + it('a restart or close before hand-over sends it back to waiting too', () => { + for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) { + expect(settle(kind)).toEqual({ state: 'waiting' }) + } + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ + state: 'waiting' + }) + }) + + it('a failure returns the card for the user to act on', () => { + for (const kind of [ + 'providerRejected', + 'providerExited', + 'hostStopped', + 'startFailed', + 'writeFailed', + 'queueFull' + ] as const) { + expect(settle(kind)).toEqual({ state: 'returned' }) + } + expect(rejectedDraftSettlement({ reason: 'the provider said no' })).toEqual({ + state: 'returned' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts new file mode 100644 index 00000000000..7e1d6949bf3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -0,0 +1,58 @@ +// The one decision for whether a committed dispatch row changes a submission's +// effective delivery answer, and what a rejection does to the draft it was +// consumed from. The reducer folds rows through the first and the queued +// draft's settlement hook fires through it, so the two can never disagree: a +// row the reducer ignores must not alter a draft. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { JournalDispatchRow } from './journal-row-schema' + +/** `rejected` and `accepted` are terminal; a late row for an absent or settled + * submission must not reopen the answer. */ +export function journalDispatchRowApplies( + submission: Pick | undefined +): boolean { + return ( + submission !== undefined && + submission.dispatchState !== 'rejected' && + submission.dispatchState !== 'accepted' + ) +} + +/** A consumed draft's submission settled `rejected`: the draft is settled by + * `rejectedDraftSettlement`, since its text has no other holder once it left + * the sender's outbox as a draft. */ +export function consumedSubmissionWasRejected( + submission: Pick | undefined +): boolean { + return submission?.dispatchState === 'rejected' +} + +/** What a consumed draft becomes when its submission is rejected. */ +export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' } + +/** + * Where no one failed the user — a Stop withdrew it, or a restart or close + * interrupted it before hand-over — the draft goes back to waiting at its own + * position, under whatever pauses the queue: the Stop's own pause, or the + * restart's, derived from the host instance. A returned card would block the + * drafts behind it on a failure that never happened. A failure returns the + * card with its refusal for the user to act on. + */ +export function rejectedDraftSettlement( + rejection: Pick & { rejection?: unknown } +): RejectedDraftSettlement { + return classifyDispatchRejection(rejection).verdict === null + ? { state: 'waiting' } + : { state: 'returned' } +} + +/** True when committing this row NEWLY settles the submission to `rejected` — + * the only transition that settles a consumed draft. */ +export function journalDispatchRowNewlyRejects( + submission: Pick | undefined, + row: Pick +): boolean { + return row.state === 'rejected' && journalDispatchRowApplies(submission) +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index f428675fb2c..52a5478c381 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -2,7 +2,7 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' import { publishNewEpoch } from './journal-epoch-rollover' import type { JournalLoad } from './journal-open' @@ -16,7 +16,7 @@ export class JournalEpochController { now: () => number mintEpoch: () => string serialize: (run: () => Promise) => Promise - database: () => { db: Database.Database } + database: () => JournalHostDatabase readOnly: () => boolean setReadOnly: (readOnly: boolean) => void highestFence: () => number @@ -27,9 +27,8 @@ export class JournalEpochController { start(reason: AgentJournalEpochReason, fence: number): void { publishNewEpoch({ - db: this.deps.database().db, - sessionId: this.deps.identity.sessionId, - providerHandle: this.deps.identity.providerHandle, + database: this.deps.database(), + identity: this.deps.identity, epoch: this.deps.mintEpoch(), reason, fence, @@ -63,7 +62,7 @@ export class JournalEpochController { assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId) assertJournalFence(fence, this.deps.highestFence()) replaceJournalEpoch({ - db: this.deps.database().db, + database: this.deps.database(), identity: this.deps.identity, reason, fence, diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 96273366d5f..646254b4932 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Republishing an epoch is ONE transaction. import { mkdtemp, rm } from 'node:fs/promises' @@ -8,12 +9,16 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' +import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch } from './journal-epoch-replacement' import type { JournalLoad } from './journal-open' -import { journalDatabaseFile } from './journal-paths' -import { readJournalEpochRows, readJournalSessionEpoch } from './journal-row-table' -import { createTrackedJournalOpener } from './journal-store-test-open' +import type { AgentSessionJournal } from './journal-store' +import { readJournalSessionEpoch } from './journal-row-table' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -23,9 +28,15 @@ const IDENTITY: AgentSessionJournalIdentity = { providerHandle: { kind: 'codex', threadId: 'thread-1' } } +const PEER: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'session-peer', + providerHandle: { kind: 'codex', threadId: 'thread-peer' } +} + let root: string let clock = 1_000 -let database: OpenJournalDatabase +let database: JournalHostDatabase const journals = createTrackedJournalOpener() function now(): number { @@ -33,6 +44,15 @@ function now(): number { return clock } +/** Rows stored under the chat and epoch, whatever the chat's pointer names. */ +function storedRows(sessionId: string, epoch: string): number { + return Number( + database.db + .prepare('SELECT count(*) AS total FROM journal_rows WHERE session_id = ? AND epoch = ?') + .get(sessionId, epoch)?.total + ) +} + function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } @@ -42,7 +62,7 @@ function replace(input: { onPublished?: (loaded: JournalLoad) => void }): void { replaceJournalEpoch({ - db: database.db, + database, identity: IDENTITY, reason: 'legacy_import', fence: 1, @@ -56,12 +76,12 @@ function replace(input: { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-')) clock = 1_000 - database = openJournalDatabase(journalDatabaseFile(root)) + database = openTestJournalHostDatabase(root) }) afterEach(async () => { try { - database.db.close() + database.close() } catch { // Already closed by the case. } @@ -81,23 +101,33 @@ describe('journal epoch replacement', () => { expect(published).toHaveLength(1) const epoch = readJournalSessionEpoch(database.db, IDENTITY.sessionId) expect(epoch).toBe(published[0]?.state.epoch) - expect(readJournalEpochRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) }) - it('discards every superseded row in the same transaction', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 }) - await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 }) + // Keyed by identity: a retired epoch's rows go by (chat, epoch), and no other chat's go with them. + it.each([ + [ + 'a replace', + (journal: AgentSessionJournal) => + journal.replaceEpochItems('legacy_import', 1, [ + { identity: item(9), body: { kind: 'status', text: 'republished' } } + ]) + ], + ['a rollover', (journal: AgentSessionJournal) => journal.rollEpoch('handle_forked', 1)] + ])('discards every superseded row in the same transaction as %s', async (_name, retire) => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const peer = await journals.open({ identity: PEER, stateDirectory: root }) + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(item(1), { kind: 'status', text: 'old' }, scope) + await journal.appendItem(item(2), { kind: 'status', text: 'older' }, scope) + await peer.appendItem(item(1), { kind: 'status', text: 'peer' }, scope) const before = journal.epoch - await journal.replaceEpochItems('legacy_import', 1, [ - { identity: item(9), body: { kind: 'status', text: 'republished' } } - ]) + await retire(journal) expect(journal.epoch).not.toBe(before) - expect(readJournalEpochRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0) - expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([ - { kind: 'status', text: 'republished' } - ]) + expect(storedRows(IDENTITY.sessionId, before)).toBe(0) + expect(storedRows(IDENTITY.sessionId, journal.epoch)).toBe(journal.cursor().sequence) + expect(storedRows(PEER.sessionId, peer.epoch)).toBe(2) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 9512d5e9fa1..8d94c5b2b5b 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -1,35 +1,41 @@ // Republishing a live item set into a fresh epoch. // -// One transaction: discard every row, insert the epoch row plus the replacement -// items, move the session projection, and retire any repair marker — this -// republished history is exactly what the marker was holding out for. +// One transaction: discard the old epoch's rows, insert the epoch row plus the +// replacement items, move the session projection, and retire any repair marker +// — this republished history is exactly what the marker was holding out for. import type { AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { - deleteAllJournalRows, + deleteJournalEpochRows, insertJournalRow, - upsertJournalSessionRow + publishJournalSessionEpoch, + readJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' -export type JournalReplacementItem = { +export type JournalReplacementItem = AgentJournalProducerLinkage & { identity: AgentJournalItemIdentity body: AgentJournalItemBody observedAt?: number + /** Absent for history rebuilt from a source that never stated one: derived from position, as + * a legacy row's is, and then written down. */ + turnScope?: AgentJournalTurnScope } export function replaceJournalEpoch(input: { - db: Database.Database + database: JournalHostDatabase identity: AgentSessionJournalIdentity reason: AgentJournalEpochReason fence: number @@ -56,26 +62,27 @@ export function replaceJournalEpoch(input: { body: item.body, seq: state.lastSequence + 1, fence: input.fence, - ts: item.observedAt ?? input.now() + ts: item.observedAt ?? input.now(), + linkage: item, + turnScope: item.turnScope ?? state.derivedTurnScope.scopeFor(item.body) }) assertJournalFence(row.fence, state.highestFence) applyJournalRow(state, row) rows.push(row) } - input.db.exec('BEGIN IMMEDIATE') - try { - deleteAllJournalRows(input.db) - clearJournalRepairMarker(input.db, input.identity.sessionId) - for (const row of rows) { - insertJournalRow(input.db, input.identity.sessionId, row) + const { sessionId } = input.identity + input.database.transaction((db) => { + const retired = readJournalSessionEpoch(db, sessionId) + if (retired !== null) { + deleteJournalEpochRows(db, sessionId, retired) } - upsertJournalSessionRow(input.db, input.identity.sessionId, epoch, epochRow.ts) - input.db.exec('COMMIT') - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + clearJournalRepairMarker(db, sessionId) + for (const row of rows) { + insertJournalRow(db, sessionId, row) + } + publishJournalSessionEpoch(db, input.identity, epoch) + }) // COMMIT landed: on disk the superseded rows are gone and this epoch is the // live one. The caller adopts that immediately, or a later failure leaves the diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 5a5f39e5a62..855f60ac326 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -6,22 +6,22 @@ // rather than retained — nothing would ever shed them. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' -import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { - deleteAllJournalRows, + deleteJournalEpochRows, insertJournalRow, - upsertJournalSessionRow + publishJournalSessionEpoch, + readJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' export function publishNewEpoch(input: { - db: Database.Database - sessionId: string - providerHandle: AgentSessionProviderHandle + database: JournalHostDatabase + identity: AgentSessionJournalIdentity epoch: string reason: AgentJournalEpochReason fence: number @@ -32,7 +32,7 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.providerHandle, + providerHandle: input.identity.providerHandle, // Carries no body: an older host must keep reading a turn-free session past row 1. v: journalRowSchemaVersion([]), epoch: input.epoch, @@ -41,22 +41,21 @@ export function publishNewEpoch(input: { ts: input.now } - input.db.exec('BEGIN IMMEDIATE') - try { - deleteAllJournalRows(input.db) - clearJournalRepairMarker(input.db, input.sessionId) - insertJournalRow(input.db, input.sessionId, row) - upsertJournalSessionRow(input.db, input.sessionId, input.epoch, input.now) - input.db.exec('COMMIT') - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + const { sessionId } = input.identity + input.database.transaction((db) => { + const retired = readJournalSessionEpoch(db, sessionId) + if (retired !== null) { + deleteJournalEpochRows(db, sessionId, retired) + } + clearJournalRepairMarker(db, sessionId) + insertJournalRow(db, sessionId, row) + publishJournalSessionEpoch(db, input.identity, input.epoch) + }) // COMMIT landed: on disk the superseded prefix is gone and this epoch is the // live one. The caller adopts that immediately, or a later failure leaves the // store writing into an epoch that no longer exists. - const state = createJournalReducerState(input.sessionId, input.epoch) + const state = createJournalReducerState(sessionId, input.epoch) applyJournalRow(state, row) state.oldestSequence = 1 input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index d6424ce7952..ed27ed6fa0b 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -1,25 +1,28 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // An empty chat beside a pre-SQLite journal explains itself. // // The SQLite move shipped no importer, so a session whose history is a // `log.jsonl` founds a fresh empty journal beside it and looks exactly like a // chat created seconds ago. One status row is the difference. -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import Database from '../../sqlite/sync-database' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' -import { openJournalDatabase } from './journal-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' +import { journalDirectoryFor } from './journal-paths' import type { AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + deleteTestJournalRow, + insertTestJournalRowJson +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -38,15 +41,21 @@ const journals = createTrackedJournalOpener() function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => (clock += 1), mintEpoch: () => `epoch-${clock}`, ...overrides }) } -function writeRemnant(name = 'log.jsonl'): Promise { - return writeFile(join(root, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') +/** Where this chat's history lived before the journal was one database per host. */ +function legacyDir(): string { + return journalDirectoryFor(root, IDENTITY) +} + +async function writeRemnant(name = 'log.jsonl'): Promise { + await mkdir(legacyDir(), { recursive: true }) + await writeFile(join(legacyDir(), name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') } function disclosure(journal: AgentSessionJournal): string | null { @@ -71,7 +80,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const journal = await open() expect(disclosure(journal)).toContain('send a message to pick up where you left off') - expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) expect(disclosure(journal)).toContain('Codex') }) @@ -84,7 +93,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const journal = await open() - expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) expect(disclosure(journal)).not.toContain('snapshot.json') }) @@ -93,7 +102,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const journal = await open() - expect(disclosure(journal)).toContain(join(root, 'snapshot.json')) + expect(disclosure(journal)).toContain(join(legacyDir(), 'snapshot.json')) }) it('says nothing to a chat that is genuinely new', async () => { @@ -130,7 +139,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const reopened = await open() - expect(disclosure(reopened)).toContain(join(root, 'log.jsonl')) + expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) }) // A repair's epoch is the marker that history was deleted and never rebuilt, @@ -142,18 +151,18 @@ describe('a chat whose history is still in the pre-SQLite format', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() // Deleting the anchor leaves every row unanchored: replay keeps nothing, so // the repair publishes an empty `unreconcilable_prefix` epoch and — costing // no malformed row — appends no disclosure of its own. That is the one state // where this branch and a repair meet. - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { - opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) } finally { - opened.db.close() + opened.close() } await writeRemnant() @@ -161,20 +170,31 @@ describe('a chat whose history is still in the pre-SQLite format', () => { expect(disclosure(repaired)).toBeNull() // Still asking the provider for the history the repair dropped. - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) }) - // A latched journal loads empty, so it reaches the same branch — and an append - // into one throws, which would make the session unopenable rather than read-only. - it('writes nothing into a journal latched by a newer schema', async () => { + // A journal latched by a newer row reaches the same branch — and an append into one throws, + // which would make the session unopenable rather than read-only. + it('writes nothing into a journal latched by a newer row', async () => { const founded = await open() + const epoch = founded.epoch await founded.close() - const db = new Database(journalDatabaseFile(root)) - try { - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - } finally { - db.close() - } + insertTestJournalRowJson( + openTestJournalHostDatabase(root).db, + IDENTITY.sessionId, + 2, + JSON.stringify({ + v: 99, + kind: 'item', + epoch, + seq: 2, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer build' } + }) + ) await writeRemnant() const latched = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts deleted file mode 100644 index 592f7471c5c..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Every path that can open a SQLite connection releases it. -// -// Asserting that the happy path closes cleanly proves nothing: these sites are -// reached only when something has already gone wrong. On POSIX a leak is -// SILENT — the unlink succeeds — so each case asserts BOTH that the sidecars -// are gone and that the directory renames and removes, which is the half that -// actually fails on Windows. - -import { access, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' -import { createTrackedJournalOpener } from './journal-store-test-open' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } -} - -let base: string -let root: string -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function runningTool(): AgentJournalItemBody { - return { kind: 'tool-call', name: 'command', input: {}, state: 'running' } -} - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -/** The platform-independent proof: an open handle blocks both of these on - * Windows, where every leak in this file actually shows up. */ -async function expectNothingHoldsTheDirectory(): Promise { - const dbPath = journalDatabaseFile(root) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - const moved = `${root}-recovered-vtest` - await rename(root, moved) - await rm(moved, { recursive: true }) - root = join(base, `journal-${Math.random().toString(36).slice(2)}`) -} - -beforeEach(async () => { - base = await mkdtemp(join(tmpdir(), 'orca-journal-handles-')) - root = join(base, 'journal') -}) - -afterEach(async () => { - await journals.closeAll() - await rm(base, { recursive: true, force: true }) -}) - -describe('the standalone probe owns its own connection', () => { - it('leaves no handle behind after fifty repeated loads', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'message', role: 'user', blocks: [] }, { fence: 1 }) - await journal.close() - - for (let attempt = 0; attempt < 50; attempt += 1) { - const loaded = await loadJournal(root, IDENTITY.sessionId) - expect(loaded?.readOnly).toBe(false) - } - await expectNothingHoldsTheDirectory() - }) - - it('leaves no handle behind after fifty loads of a latched future schema', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.close() - const seeded = openJournalDatabase(journalDatabaseFile(root)) - seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 3}`) - seeded.db.close() - - for (let attempt = 0; attempt < 50; attempt += 1) { - // The latched open closes the probe connection and returns the read-only - // reopen, so probing repeatedly leaves nothing on a file we must not touch. - expect((await loadJournal(root, IDENTITY.sessionId))?.readOnly).toBe(true) - } - // A read-only connection cannot remove the sidecars it materialized, so only - // the rename/remove half is expected to hold here. - const moved = `${root}-recovered-vtest` - await rename(root, moved) - await rm(moved, { recursive: true }) - root = join(base, 'journal-after-latched') - }) - - it('returns null for a session with no journal, without creating one', async () => { - expect(await loadJournal(root, IDENTITY.sessionId)).toBeNull() - expect(await exists(journalDatabaseFile(root))).toBe(false) - }) -}) - -describe('failure paths inside the open call', () => { - // Site 1: the raw connection is owned by `openJournalDatabase` until it returns. - it('closes the raw connection when the version read cannot run', async () => { - await journals.open({ identity: IDENTITY, journalDir: root }).then((journal) => journal.close()) - await writeFile(journalDatabaseFile(root), 'this is not a database', 'utf8') - - expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow() - await expectNothingHoldsTheDirectory() - }) - - it('closes the raw connection when the migration cannot start', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.close() - const blocker = openJournalDatabase(journalDatabaseFile(root)) - // Roll the stored version back so the migration runs, then hold the write - // lock it needs: the throw lands after the connection already exists. - blocker.db.pragma('user_version = 0') - blocker.db.exec('BEGIN IMMEDIATE') - blocker.db.exec("INSERT INTO journal_sessions VALUES ('other', 'e', 1)") - try { - expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow() - } finally { - blocker.db.exec('ROLLBACK') - blocker.db.close() - } - await expectNothingHoldsTheDirectory() - }, 60_000) - - // Sites 2 and 3: `open()` closes its own connection on any throw after the - // connection exists, which is what lets the factory need no `finally`. - it('leaves nothing open when a post-connection step of open() throws', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), runningTool(), { fence: 1 }) - await journal.close() - - // Replay runs after the connection is open, so a read it cannot serve - // throws with the handle already held. - const exec = vi.spyOn(Database.prototype, 'prepare').mockImplementation(() => { - throw new Error('replay cannot read this journal') - }) - try { - await expect(journals.open({ identity: IDENTITY, journalDir: root })).rejects.toThrow( - 'replay cannot read this journal' - ) - } finally { - exec.mockRestore() - } - await expectNothingHoldsTheDirectory() - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts new file mode 100644 index 00000000000..9ce0e0c3431 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -0,0 +1,155 @@ +// The host journal database for a test's state directory. One per directory per test process, as +// one per state directory per host process in production, so a test that "restarts" a host on the +// same directory reads the same database the way a restarted host would. + +import { resolve } from 'node:path' +import { JournalHostDatabase } from './journal-host-database' +import { replayJournal, type JournalLoad } from './journal-open' +import { serializeJournalRow, type JournalRow } from './journal-row-schema' +import { + iterateJournalEpochRows, + publishJournalSessionEpoch, + readJournalSessionEpoch, + type JournalStoredRow +} from './journal-row-table' +import type Database from '../../sqlite/sync-database' +import type { AgentSessionJournal } from './journal-store' +import type { AgentSessionJournalOptions } from './journal-store-contracts' +import { openAgentSessionJournal } from './journal-store-factory' + +const opened = new Map() + +export function openTestJournalHostDatabase(stateDirectory: string): JournalHostDatabase { + const directory = resolve(stateDirectory) + const existing = opened.get(directory) + if (existing && !existing.isClosed) { + return existing + } + const database = JournalHostDatabase.open(directory) + opened.set(directory, database) + return database +} + +/** Closes every database this process opened for tests. */ +export function closeTestJournalHostDatabases(): void { + for (const database of opened.values()) { + database.close() + } + opened.clear() +} + +export type TestJournalOptions = Omit & { + /** The state directory whose one journal database holds this chat. */ + stateDirectory: string +} + +export type TrackedJournalOpener = { + open: (options: TestJournalOptions) => Promise + track: (journal: T) => T + /** Drains every tracked journal, then closes the databases. */ + closeAll: () => Promise +} + +export function createTrackedJournalOpener(): TrackedJournalOpener { + const journals: AgentSessionJournal[] = [] + return { + open: async ({ stateDirectory, ...options }) => { + const journal = await openAgentSessionJournal({ + ...options, + database: openTestJournalHostDatabase(stateDirectory) + }) + journals.push(journal) + return journal + }, + track: (journal) => { + journals.push(journal) + return journal + }, + closeAll: async () => { + await Promise.allSettled(journals.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() + } + } +} + +/** What a fresh open of the chat would replay, read from the test state directory's database. */ +export function loadTestJournal(stateDirectory: string, sessionId: string): JournalLoad | null { + return replayJournal(openTestJournalHostDatabase(stateDirectory).db, sessionId) +} + +// Staging on-disk states for a case, addressed the way the case thinks of them: by chat and sequence. + +function liveEpoch(db: Database.Database, sessionId: string): string { + const epoch = readJournalSessionEpoch(db, sessionId) + if (epoch === null) { + throw new Error(`no journal for ${sessionId}`) + } + return epoch +} + +/** Points the chat at a new epoch, as a publish would. */ +export function publishTestJournalEpoch( + db: Database.Database, + sessionId: string, + epoch: string +): void { + publishJournalSessionEpoch(db, { sessionId, workspaceId: 'ws-1' }, epoch) +} + +/** The chat's rows of `epoch` — none once that epoch is no longer the live one. */ +export function readTestJournalRows( + db: Database.Database, + sessionId: string, + epoch: string +): JournalStoredRow[] { + return readJournalSessionEpoch(db, sessionId) === epoch + ? [...iterateJournalEpochRows(db, sessionId, epoch)] + : [] +} + +/** The chat's rows of whichever epoch is live now. */ +export function liveTestJournalRows(db: Database.Database, sessionId: string): JournalStoredRow[] { + const epoch = readJournalSessionEpoch(db, sessionId) + return epoch === null ? [] : [...iterateJournalEpochRows(db, sessionId, epoch)] +} + +/** The row under the chat's live epoch, whatever epoch its body names. */ +export function insertTestJournalRow( + db: Database.Database, + sessionId: string, + row: JournalRow +): void { + insertTestJournalRowJson(db, sessionId, row.seq, serializeJournalRow(row), row.ts) +} + +/** A raw `row_json` at `seq`, the way a newer build or a bad write would leave it. */ +export function insertTestJournalRowJson( + db: Database.Database, + sessionId: string, + seq: number, + rowJson: string, + ts = 1 +): void { + db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ).run(sessionId, liveEpoch(db, sessionId), seq, ts, rowJson) +} + +export function updateTestJournalRowJson( + db: Database.Database, + sessionId: string, + seq: number, + rowJson: string +): void { + db.prepare( + 'UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND epoch = ? AND seq = ?' + ).run(rowJson, sessionId, liveEpoch(db, sessionId), seq) +} + +export function deleteTestJournalRow(db: Database.Database, sessionId: string, seq: number): void { + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq = ?').run( + sessionId, + liveEpoch(db, sessionId), + seq + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts new file mode 100644 index 00000000000..84ff9775022 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -0,0 +1,260 @@ +// The one connection every chat's writes share: its transactions, its busy handling, and the order +// in which it closes at quit. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { tearDownRuntime } from '../../runtime/structured-agent-session-runtime-teardown' +import { journalDatabasePath } from './journal-host-database' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import type Database from '../../sqlite/sync-database' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const OTHER: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'session-2', + providerHandle: { kind: 'codex', threadId: 'thread-2' } +} + +const SUBMISSION = { + clientMessageId: 'msg-1', + payloadFingerprint: 'e'.repeat(64), + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'hi' }] + }, + fence: 1 +} + +let root: string +const journals = createTrackedJournalOpener() + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +/** The next row insert breaks a deferred foreign key, so its transaction's COMMIT fails and SQLite + * leaves the transaction open, as it may for a COMMIT that fails on its own. */ +function failCommits(db: Database.Database): () => void { + db.exec(`CREATE TEMP TABLE commit_probe_parent (id INTEGER PRIMARY KEY); +CREATE TEMP TABLE commit_probe_child ( + parent INTEGER REFERENCES commit_probe_parent (id) DEFERRABLE INITIALLY DEFERRED +); +CREATE TEMP TRIGGER commit_probe AFTER INSERT ON main.journal_rows +BEGIN INSERT INTO commit_probe_child (parent) VALUES (1); END`) + return () => db.exec('DROP TRIGGER temp.commit_probe') +} + +function text(value: string) { + return { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: value }] + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-host-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the shared connection', () => { + // T8: another connection holding the write lock makes an append wait, not fail. + it('waits out another writer instead of failing the append', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const holder = spawnProcess({ + program: process.execPath, + args: [ + '-e', + `const { DatabaseSync } = require('node:sqlite') +const db = new DatabaseSync(process.argv[1]) +db.exec('BEGIN IMMEDIATE') +process.stdout.write('holding') +setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, + journalDatabasePath(root) + ], + timeoutMs: 30_000 + }) + const exited = new Promise((resolve) => holder.once('exit', resolve)) + await new Promise((resolve) => holder.stdout.once('data', () => resolve())) + + await expect( + journal.appendItem(item(1), text('after the wait'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + expect(await exited).toBe(0) + }) + + // The #22993 companion: a transaction that cannot begin fails that write alone. + it('fails only the write whose transaction could not begin', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const exec = database.db.exec.bind(database.db) + const busy = Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 5 + }) + vi.spyOn(database.db, 'exec').mockImplementationOnce(() => { + throw busy + }) + + await expect( + journal.appendItem(item(1), text('refused'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toBe(busy) + vi.mocked(database.db.exec).mockImplementation(exec) + await expect( + journal.appendItem(item(2), text('next'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).resolves.toBeDefined() + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([text('next')]) + }) + + it('frees the connection after a failed COMMIT: nothing adopted, published or acknowledged', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const other = await journals.open({ identity: OTHER, stateDirectory: root }) + const connection = openTestJournalHostDatabase(root).db + const published = vi.fn() + journal.observeCommits(published) + const cursor = journal.cursor() + const rows = () => + readTestJournalRows(connection, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + const rowsBefore = rows() + const stopFailing = failCommits(connection) + + await expect(journal.appendSubmission(SUBMISSION)).rejects.toThrow( + 'FOREIGN KEY constraint failed' + ) + expect(connection.isTransaction).toBe(false) + stopFailing() + + expect(published).not.toHaveBeenCalled() + expect(journal.cursor()).toEqual(cursor) + expect(journal.submissions()).toEqual([]) + expect(rows()).toEqual(rowsBefore) + await expect( + other.appendItem(item(1), text('another chat'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + }) + + it('refuses every chat while a failed transaction will not roll back, then serves them', async () => { + const other = await journals.open({ identity: OTHER, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const connection = database.db + const exec = connection.exec.bind(connection) + let rollbackFails = true + vi.spyOn(connection, 'exec').mockImplementation((sql) => { + if (sql === 'ROLLBACK' && rollbackFails) { + throw Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + } + exec(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const stopFailing = failCommits(connection) + + // A first-use copy's batch, under the unsynced level. Its caller gets the COMMIT's own error. + expect(() => + database.unsyncedTransaction((db) => + db + .prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + .run('copying', 'epoch-copying', 1, 1, '{}') + ) + ).toThrow('FOREIGN KEY constraint failed') + expect(connection.isTransaction).toBe(true) + await expect( + other.appendItem(item(1), text('refused'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + }) + + rollbackFails = false + expect(database.db.isTransaction).toBe(false) + stopFailing() + // Restored with the ROLLBACK: no later commit runs at the copy's unsynced level. + expect(Number(connection.pragma('synchronous', { simple: true }))).toBe(2) + await expect( + other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).resolves.toBeDefined() + expect(readTestJournalRows(connection, OTHER.sessionId, other.epoch)).not.toHaveLength(0) + }) + + it('refuses a transaction body that awaits', () => { + const database = openTestJournalHostDatabase(root) + expect(() => database.transaction(async () => undefined)).toThrow('must not await') + // Rolled back: the connection is free for the next transaction. + expect(database.db.isTransaction).toBe(false) + }) +}) + +describe('quit', () => { + // T-quit-drain: a sink write still in flight while the host flushes lands before the one + // connection closes, because the connection closes last. + it('closes the one connection only after the host has flushed what was in flight', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const installed = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `flushAllStreamedEvents` on the host. + host: { + flushAllStreamedEvents: async () => { + // A child's last row, delivered while quit is draining its sink. + await new Promise((resolve) => setTimeout(resolve, 10)) + await journal.appendItem(item(1), text('written during quit'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + } as never, + adapter: { closeAll: async () => undefined }, + journalDatabase: database, + waitForRecovery: async () => undefined + } + + await tearDownRuntime(installed, 'quit') + + expect(database.isClosed).toBe(true) + closeTestJournalHostDatabases() + const reopened = openTestJournalHostDatabase(root) + expect( + readTestJournalRows(reopened.db, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + ).toEqual([1, 2]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts new file mode 100644 index 00000000000..b6999e4ef55 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -0,0 +1,120 @@ +// The host's one chat journal database: every structured chat on this state directory, in one +// file, on one connection. The app and orcad instance locks keep a second process off a profile. +// +// A chat's store owns no connection. It goes through this object, so there is nothing per chat to +// open, close, retry or leak, and the connection closes exactly once, last, at host teardown. + +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import { + JOURNAL_SYNCHRONOUS, + openJournalDatabase, + runJournalTransaction, + type OpenJournalDatabase +} from './journal-database' +import { journalOpenRefusalError } from './journal-open-failure' +import { journalDirectoryFor } from './journal-paths' +import { AgentSessionJournalError } from './journal-write-guards' + +const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' + +export function journalDatabasePath(stateDirectory: string): string { + return join(stateDirectory, JOURNAL_DATABASE_FILE) +} + +export class JournalHostDatabase { + private connection: Database.Database | null + /** A newer Orca wrote the database: every chat's history reads, and no chat writes. */ + readonly readOnly: boolean + /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ + private stranded = false + + private constructor( + readonly stateDirectory: string, + opened: OpenJournalDatabase + ) { + this.connection = opened.db + this.readOnly = opened.readOnly + } + + static open(stateDirectory: string): JournalHostDatabase { + mkdirSync(stateDirectory, { recursive: true }) + return new JournalHostDatabase( + stateDirectory, + openJournalDatabase(journalDatabasePath(stateDirectory)) + ) + } + + get isClosed(): boolean { + return this.connection === null + } + + get db(): Database.Database { + const connection = this.connection + if (!connection) { + throw new AgentSessionJournalError('journal_closed', 'the chat journal database is closed') + } + if (this.stranded) { + this.rollBackStrandedTransaction(connection) + } + return connection + } + + /** One IMMEDIATE transaction; see `runJournalTransaction`. */ + transaction(run: (db: Database.Database) => T): T { + return runJournalTransaction(this.db, run, () => { + this.stranded = true + }) + } + + /** + * The same transaction, committed without an fsync: for rows no reader follows until a later + * synced commit, which under WAL makes every earlier frame durable too. The setting is restored + * in the same task, so no other chat's commit runs under it. + */ + unsyncedTransaction(run: (db: Database.Database) => T): T { + const db = this.db + db.pragma('synchronous = NORMAL') + try { + return this.transaction(run) + } finally { + // SQLite refuses the change inside a transaction; freeing a stranded one restores it. + if (!db.isTransaction) { + db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + } + } + } + + /** Where this chat's history lived before the journal was one database per host. */ + legacyDirectoryFor( + identity: Pick + ): string { + return journalDirectoryFor(this.stateDirectory, identity) + } + + /** Last, after every store has drained. A close that fails keeps the handle, so the retried + * teardown closes this same connection. */ + close(): void { + this.connection?.close() + this.connection = null + } + + /** + * A failed transaction whose ROLLBACK failed too is still open: every later BEGIN would fail + * inside it and every read would see rows that never committed. Each use retries the ROLLBACK, + * and until one goes through every chat is refused the way a journal that will not open is. + */ + private rollBackStrandedTransaction(connection: Database.Database): void { + if (connection.isTransaction) { + try { + connection.exec('ROLLBACK') + } catch (error) { + throw journalOpenRefusalError(error) + } + } + connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + this.stranded = false + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts new file mode 100644 index 00000000000..10aee4a99c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -0,0 +1,97 @@ +// Folding one item write into the render model: the revision, tombstone and creating-write rules +// the reducer header states. Turn scope is the creating write's too, with one exception, which the +// submission fold owns: a queued message's scope and position are its handover's. + +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalLinkageFields, + isRootAgentJournalItem, + namesAgentJournalProducer +} from '../../../shared/agent-session-journal-producer' +import type { JournalReducerState } from './journal-reducer' + +export function statedOrDerivedTurnScope( + state: JournalReducerState, + write: { body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } +): AgentJournalTurnScope { + return write.turnScope ?? state.derivedTurnScope.scopeFor(write.body) +} + +export function upsertJournalItem( + state: JournalReducerState, + itemId: string, + revision: number, + next: AgentJournalRenderItem, + /** The creating write's fence: the generation a running turn belongs to. */ + fence: number +): void { + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (!existing) { + state.items.set(itemId, next) + state.itemFences.set(itemId, fence) + state.tombstones.delete(itemId) + return + } + // Creation sequence is the ordering key; a revision refreshes content only. + // `observedAt` is pinned with it: clients sort the timeline by that timestamp, + // so letting a revision advance it makes the row jump past everything that + // landed in between — the provider's own echo of a send revises the submission + // row, which relocated the user's bubble below later rows. + const submitted = + existing.body.kind === 'message' && + existing.body.role === 'user' && + parseAgentJournalItemKey(itemId)?.provider === 'orca' + const { sequenceIndex: _revisedAt, ...revised } = next + state.items.set(itemId, { + ...revised, + // Settlements, prompt answers and reopen sweeps revise rows any agent wrote + // without naming one; each would otherwise hand a subagent's row to the session. + ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), + // Provider history may normalize text or omit local attachments from the original send. + body: submitted ? existing.body : next.body, + sequence: existing.sequence, + ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), + observedAt: existing.observedAt, + turnScope: existing.turnScope ?? next.turnScope + }) + state.tombstones.delete(itemId) +} + +export function removeJournalItem( + state: JournalReducerState, + itemId: string, + revision: number +): void { + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + if (existing) { + state.derivedTurnScope.observe( + itemId, + isRootAgentJournalItem(existing), + existing.body, + undefined + ) + } + state.tombstones.set(itemId, revision) + state.items.delete(itemId) + state.itemFences.delete(itemId) +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts index 0e05fc3fc93..58d6e58bda7 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey, - agentJournalSubmissionKey, boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS, parseAgentJournalItemKey @@ -139,12 +138,6 @@ describe('key encoding', () => { }) expect(orca).not.toBe(legacy) }) - - it('derives the submission slot from the same function the reducer uses', () => { - expect(agentJournalSubmissionKey('cm_42')).toBe( - agentJournalItemKey({ provider: 'orca', clientMessageId: 'cm_42' }) - ) - }) }) describe('bounded component domain separation', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 92b085e2e0f..a38a37fd0c8 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Legacy import runs the existing per-agent transcript decoders and keys the // results by identity read off the same raw lines. Fixtures are shaped like the // files the providers actually write. @@ -18,6 +19,7 @@ import { importLegacyTranscriptIntoJournal } from './journal-legacy-import' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' +import { openTestJournalHostDatabase } from './journal-host-database-test-support' // No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. const decodedClaudeOverride = vi.hoisted((): { message: NativeChatMessage | null } => ({ @@ -72,7 +74,7 @@ async function open( ): Promise { return openAgentSessionJournal({ identity: identity(agent, sessionId), - journalDir: root, + database: openTestJournalHostDatabase(root), now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -477,7 +479,9 @@ describe('payload bounds on import', () => { describe('import failures', () => { it('rejects a legacy source above the fixed 16 MiB import cap before decoding', async () => { const journalDir = join(root, 'oversized-source-journal') - const journal = await open('claude', CLAUDE_SESSION, { journalDir }) + const journal = await open('claude', CLAUDE_SESSION, { + database: openTestJournalHostDatabase(journalDir) + }) const filePath = join(root, 'oversized-source.jsonl') await writeFile(filePath, 'x'.repeat(16 * 1024 * 1024 + 1), 'utf8') const epoch = journal.epoch @@ -501,7 +505,9 @@ describe('import failures', () => { it('bounds oversized legacy tool-call input before journal publication', async () => { const journalDir = join(root, 'bounded-tool-input-journal') const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 } - const journal = await open('claude', CLAUDE_SESSION, { journalDir }) + const journal = await open('claude', CLAUDE_SESSION, { + database: openTestJournalHostDatabase(journalDir) + }) const filePath = await writeFixture('oversized-tool-input.jsonl', [ { parentUuid: null, @@ -567,7 +573,7 @@ describe('import failures', () => { await journal.appendItem( { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'kept' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const before = journal.epoch const metadataOnly = await writeFixture('metadata-only.jsonl', [ @@ -629,7 +635,7 @@ describe('multi-block legacy messages', () => { it('bounds a Claude tool call that shares its message with narration', async () => { const journal = await open('claude', CLAUDE_SESSION, { - journalDir: join(root, 'claude-mixed-journal') + database: openTestJournalHostDatabase(join(root, 'claude-mixed-journal')) }) const filePath = await writeFixture('claude-mixed.jsonl', [ { @@ -675,7 +681,7 @@ describe('multi-block legacy messages', () => { it('bounds a Grok tool call that shares its row with assistant text', async () => { const journal = await open('grok', CODEX_SESSION, { - journalDir: join(root, 'grok-mixed-journal') + database: openTestJournalHostDatabase(join(root, 'grok-mixed-journal')) }) const filePath = await writeFixture('grok-mixed.jsonl', [ { @@ -703,7 +709,7 @@ describe('multi-block legacy messages', () => { it('bounds an omp execution cell, whose invocation always ships with its output', async () => { const journal = await open('omp', CODEX_SESSION, { - journalDir: join(root, 'omp-mixed-journal') + database: openTestJournalHostDatabase(join(root, 'omp-mixed-journal')) }) const filePath = await writeFixture('omp-mixed.jsonl', [ { diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index a5427f76fdc..2e9e21d024f 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -7,10 +7,13 @@ import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, - journalOpenReadRefusal + journalOpenReadRefusal, + journalOpenRefusal, + journalOpenRefusalError } from './journal-open-failure' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' +import { AgentSessionJournalError } from './journal-write-guards' +import { journalDatabasePath } from './journal-host-database' +import { replayJournal } from './journal-open' let root: string @@ -22,10 +25,15 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -/** What the journal's own open throws for the file as it stands. */ +/** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - loadJournal(root, 'session-1') + const db = openJournalDatabase(journalDatabasePath(root)).db + try { + replayJournal(db, 'session-1') + } finally { + db.close() + } } catch (error) { return error } @@ -43,17 +51,17 @@ function systemError(code: string, errno: number): Error { describe('classifyJournalOpenFailure', () => { it('calls a journal that is not a database corrupt', async () => { - await writeFile(journalDatabaseFile(root), 'not a database '.repeat(64)) + await writeFile(journalDatabasePath(root), 'not a database '.repeat(64)) const error = openFailure() expect(error).toMatchObject({ errcode: 26 }) expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') }) it('calls a journal whose pages are damaged corrupt', async () => { - const path = journalDatabaseFile(root) - const opened = openJournalDatabase(path) - opened.db.exec('PRAGMA journal_mode = DELETE') - opened.db.close() + const path = journalDatabasePath(root) + const opened = openJournalDatabase(path).db + opened.exec('PRAGMA journal_mode = DELETE') + opened.close() const bytes = await readFile(path) // Page 1 holds the header and schema; every table's root page follows it. bytes.fill(0xab, 4096) @@ -157,3 +165,33 @@ describe('createJournalOpenReadRefusals', () => { vi.restoreAllMocks() }) }) + +// Only an update gets past a journal a newer Orca wrote, so it has a reason of its own: a client +// that chose words by `journalUnavailable` said to try again, and retrying never cleared it. +describe('a journal a newer Orca wrote', () => { + const readOnly = () => + new AgentSessionJournalError('journal_read_only', 'the journal uses a newer schema') + + it('refuses a write with its own reason, and the words released clients print', () => { + // As the wire carries it (the mobile and older-client tests read this shape). + expect(JSON.parse(JSON.stringify(journalOpenRefusal(readOnly())))).toEqual({ + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + }) + expect(journalOpenRefusalError(readOnly()).refusal).toMatchObject({ + details: { reason: 'journalWrittenByNewerOrca' } + }) + }) + + it('refuses a read with the same reason', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect(journalOpenReadRefusal(readOnly()).refusal).toMatchObject({ + details: { reason: 'journalWrittenByNewerOrca' } + }) + expect(createJournalOpenReadRefusals().refusal('session-1', readOnly()).refusal).toMatchObject({ + details: { reason: 'journalWrittenByNewerOrca' } + }) + vi.restoreAllMocks() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 5e4cff1f3ee..5e014c279cb 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -1,24 +1,47 @@ // What a failed journal open means for the chat: its history is damaged, which no retry reads -// past, or the open failed in a way that can clear (a lock, permissions, too many open files). +// past; a newer Orca wrote it, which only an update gets past; or the open failed in a way that can +// clear (a lock, permissions, too many open files). import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import { AgentSessionRefusalError, + agentSessionRefusalError, isAgentSessionRefusalError, - refuse + refuse, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' +import { AgentSessionJournalError } from './journal-write-guards' -export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> +type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unreadable'> + +/** Why an open failed, as the storage shows it; a newer Orca's journal is told apart first. */ +export type JournalOpenFailure = Exclude + +/** A per-chat file whose copy did not read back as the file: the history is not usable here. */ +export class JournalImportMismatchError extends Error { + override readonly name = 'JournalImportMismatchError' +} + +/** A database only an unreleased development build wrote: left as found, never migrated. */ +export class JournalUnreleasedSchemaError extends Error { + override readonly name = 'JournalUnreleasedSchemaError' +} // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Damage only where the storage says so; anything unproven can clear. */ +/** Unusable only where proven: damage the storage reports, a copy that did not verify, or a file + * only an unreleased build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { - if (isSqliteCorruption(current)) { + if ( + isSqliteCorruption(current) || + current instanceof JournalImportMismatchError || + current instanceof JournalUnreleasedSchemaError + ) { return 'journalCorrupt' } current = current instanceof Error ? current.cause : undefined @@ -26,6 +49,59 @@ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { return 'journalUnavailable' } +// Released clients print a refusal's message for a send; it fits a Stop too. +const JOURNAL_OPEN_MESSAGE: Record = { + journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), + journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']), + journalWrittenByNewerOrca: agentSessionWriteNoticeEnglish([ + 'savedByNewerOrca', + 'updateOrcaToKeepUsing' + ]) +} + +export const JOURNAL_NEWER_SCHEMA_MESSAGE = JOURNAL_OPEN_MESSAGE.journalWrittenByNewerOrca + +/** A newer Orca wrote the database, or one of this chat's rows: an update is what gets past it. */ +export function isJournalWrittenByNewerOrca(error: unknown): boolean { + return error instanceof AgentSessionJournalError && error.code === 'journal_read_only' +} + +/** Why a journal open failed. A newer Orca's journal clears when this Orca is updated, so it is + * not damage. */ +function journalRefusalReason(error: unknown): JournalRefusalReason { + return isJournalWrittenByNewerOrca(error) + ? 'journalWrittenByNewerOrca' + : classifyJournalOpenFailure(error) +} + +/** Why a journal open failed, and the words a released client prints for it. */ +function journalOpenFailureWords(error: unknown): { + reason: JournalRefusalReason + message: string +} { + const reason = journalRefusalReason(error) + return { reason, message: JOURNAL_OPEN_MESSAGE[reason] } +} + +/** The refusal a failed journal open answers with, as the wire carries it. One already classified + * passes through as it is: its own cause is not on it to classify again. */ +export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { + if (isAgentSessionRefusalError(error)) { + return error.refusal + } + const { reason, message } = journalOpenFailureWords(error) + return refuse('agent_session_journal_unreadable', { reason }, message) +} + +/** The same refusal, thrown: for a host that could not open the journal at all. */ +export function journalOpenRefusalError(error: unknown): AgentSessionRefusalError { + if (isAgentSessionRefusalError(error)) { + return error + } + const { reason, message } = journalOpenFailureWords(error) + return agentSessionRefusalError('agent_session_journal_unreadable', { reason }, message) +} + /** * What a read throws when the conversation it reaches cannot be opened. The storage's own text * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal, @@ -35,7 +111,7 @@ export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError if (isAgentSessionRefusalError(error)) { return error } - return unreadableRefusal(error, classifyJournalOpenFailure(error), true) + return unreadableRefusal(error, journalRefusalReason(error), true) } const MAX_LOGGED_SESSIONS = 256 @@ -51,7 +127,7 @@ export function createJournalOpenReadRefusals() { if (isAgentSessionRefusalError(error)) { return error } - const reason = classifyJournalOpenFailure(error) + const reason = journalRefusalReason(error) const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` const repeat = logged.get(sessionId) === failure // Past the cap a new session logs every failure rather than evict another's. @@ -69,7 +145,7 @@ export function createJournalOpenReadRefusals() { function unreadableRefusal( error: unknown, - reason: JournalOpenFailure, + reason: JournalRefusalReason, log: boolean ): AgentSessionRefusalError { if (log) { diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index d86da773f71..ae753839c40 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -6,10 +6,7 @@ // in the surviving sequence is corruption, and the caller rolls the epoch // rather than rendering a partial timeline. -import { existsSync } from 'node:fs' import type Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' import { applyJournalRow, createJournalReducerState, @@ -29,7 +26,7 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState - /** A future schema version was met: no writes, no deletion. */ + /** A row from a future schema was met: no writes, no deletion. */ readOnly: boolean /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ corrupt: boolean @@ -41,24 +38,48 @@ export type JournalLoad = { truncateFrom?: number } -/** - * Replay on a connection this function does NOT own. Returns null when the - * session has no journal yet. - */ -export function replayJournal( - db: Database.Database, - readOnly: boolean, - sessionId: string -): JournalLoad | null { - if (readOnly) { - return emptyReadOnlyLoad(sessionId) - } +/** Replays one chat from the host's database. Returns null when the chat has no journal yet. */ +export function replayJournal(db: Database.Database, sessionId: string): JournalLoad | null { const epoch = readJournalSessionEpoch(db, sessionId) - if (!epoch) { + if (epoch === null) { return null } - const state = createJournalReducerState(sessionId, epoch) - const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch) + return foldJournalRows({ + sessionId, + epoch, + repairedFrom: pendingJournalRepairSequence(db, sessionId, epoch), + rows: iterateJournalEpochRows(db, sessionId, epoch) + }) +} + +/** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ +function foldJournalRows( + input: JournalRowFoldInput & { rows: Iterable<{ seq: number; rowJson: string }> } +): JournalLoad { + const fold = startJournalRowFold(input) + for (const entry of input.rows) { + if (!fold.add(entry)) { + break + } + } + return fold.finish() +} + +type JournalRowFoldInput = { + sessionId: string + epoch: string + /** The sequence a pending repair on this epoch left free. */ + repairedFrom: number | null +} + +/** The same fold, fed a row at a time, for a caller that yields between batches of rows. */ +export function startJournalRowFold(input: JournalRowFoldInput): { + /** False once the fold has stopped: the rest of the rows are not read. */ + add: (entry: { seq: number; rowJson: string }) => boolean + finish: () => JournalLoad +} { + const { repairedFrom } = input + const state = createJournalReducerState(input.sessionId, input.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE let gapSequence: number | undefined let unanchoredSequence: number | undefined @@ -69,22 +90,22 @@ export function replayJournal( let latched = false let truncateFrom: number | undefined - for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) { + const add = (entry: { seq: number; rowJson: string }): boolean => { const parsed = parseJournalRow(entry.rowJson) if (!parsed.ok) { truncateFrom = entry.seq latched = parsed.unreadable malformedRows = parsed.unreadable ? 0 : 1 - break + return false } const row = parsed.row // Parse past a gap so an unreadable future row still latches read-only. if (gapSequence !== undefined) { - continue + return true } if (row.seq !== expectedSequence) { gapSequence = row.seq - continue + return true } expectedSequence += 1 if (row.seq === FIRST_JOURNAL_SEQUENCE) { @@ -95,7 +116,7 @@ export function replayJournal( } } if (!anchor) { - continue + return true } applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID @@ -103,22 +124,26 @@ export function replayJournal( repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 } + return true } - // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. - truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom - state.oldestSequence = FIRST_JOURNAL_SEQUENCE - return { - state, - readOnly: latched, - corrupt: - gapSequence !== undefined || - malformedRows > 0 || - (!latched && !anchor) || - (repairedFrom !== null && !repairHasContent) || - (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), - malformedRows, - ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) + const finish = (): JournalLoad => { + // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. + truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom + state.oldestSequence = FIRST_JOURNAL_SEQUENCE + return { + state, + readOnly: latched, + corrupt: + gapSequence !== undefined || + malformedRows > 0 || + (!latched && !anchor) || + (repairedFrom !== null && !repairHasContent) || + (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), + malformedRows, + ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) + } } + return { add, finish } } /** Rows after a cursor, in sequence order. Stops at the first row this build @@ -140,27 +165,3 @@ export function readJournalRowsAfterCursor( } return rows } - -/** Standalone probe. Opens its own connection and closes it before returning, - * so a caller holding only the returned value holds no handle. */ -export function loadJournal(journalDir: string, sessionId: string): JournalLoad | null { - const dbPath = journalDatabaseFile(journalDir) - if (!existsSync(dbPath)) { - return null - } - const opened = openJournalDatabase(dbPath) - try { - return replayJournal(opened.db, opened.readOnly, sessionId) - } finally { - opened.db.close() - } -} - -function emptyReadOnlyLoad(sessionId: string): JournalLoad { - return { - state: createJournalReducerState(sessionId, ''), - readOnly: true, - corrupt: false, - malformedRows: 0 - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts index c21a7675f00..cb7d44e8c83 100644 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -1,24 +1,17 @@ -// Where a session journal lives. +// Where a chat's history lived when the journal was one database per chat. // -// Host-side per-workspace state, keyed by workspace id — never inside the -// user's working tree. A journal in the tree would show up in `git status`, -// vanish with `git worktree remove`, and have no defined home in a folder -// workspace that is not a repository at all. Keying by id rather than by path -// makes a worktree, a folder workspace, a WSL distro, and an SSH host identical. -// -// The host environment port supplies the root so desktop Electron and the -// headless/SSH runtime resolve their own durable state directories. +// Nothing writes here any more: the host's one database replaced it. The importer copies a file it +// finds here on that chat's first use and deletes it once the copy verifies; the pre-SQLite format +// remnant check looks here too. +// Host-side per-workspace state, keyed by hashed ids — never inside the user's working tree. import { createHash } from 'node:crypto' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { getAppEnvironment } from '../../../shared/app-environment' const JOURNAL_DIR_NAME = 'agent-session-journal' -/** Filesystem-safe, collision-resistant segment for an arbitrary id. Ids come - * from providers and workspaces and can contain path separators or characters - * Windows rejects, so they are hashed rather than sanitized. */ +/** Filesystem-safe, collision-resistant segment for an arbitrary id. */ export function journalPathSegment(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) } @@ -36,14 +29,9 @@ export function journalDirectoryFor( ) } -/** Default host state root. */ -export function defaultJournalRoot(): Promise { - return Promise.resolve(getAppEnvironment().getPath('userData')) -} +const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' -export const JOURNAL_DATABASE_FILE = 'journal.db' - -/** The session's SQLite database, inside the directory `journalDirectoryFor` names. */ -export function journalDatabaseFile(journalDir: string): string { - return join(journalDir, JOURNAL_DATABASE_FILE) +/** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */ +export function legacyJournalDatabaseFile(journalDir: string): string { + return join(journalDir, LEGACY_JOURNAL_DATABASE_FILE) } diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts new file mode 100644 index 00000000000..ea28ba4ef83 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -0,0 +1,761 @@ +// A chat's per-chat journal file from an earlier build is copied into the host's one database on +// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that +// reappears after a downgrade is set aside on disk, and the chat keeps this build's history. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type * as NodeFs from 'node:fs' +import { existsSync, rmSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import Database from '../../sqlite/sync-database' +import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + liveTestJournalRows, + loadTestJournal, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' +import { importPerSessionJournal } from './journal-per-session-import' +import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rmSync: vi.fn(actual.rmSync) } +}) + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-legacy', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function legacyDir(): string { + return journalDirectoryFor(root, IDENTITY) +} + +/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ +async function historyRows( + epoch = 'epoch-from-the-earlier-build', + reply = 'On it.' +): Promise<{ epoch: string; rows: JournalStoredRow[] }> { + const scratch = join(root, `scratch-${epoch}`) + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: scratch, + now: () => (clock += 1), + mintEpoch: () => epoch + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.appendItem( + item(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const rows = readTestJournalRows( + openTestJournalHostDatabase(scratch).db, + IDENTITY.sessionId, + journal.epoch + ) + return { epoch: journal.epoch, rows } +} + +/** The per-chat file an earlier build left, in its own schema. */ +async function writeLegacyJournal(epoch: string, rows: readonly JournalStoredRow[]): Promise { + const path = legacyJournalDatabaseFile(legacyDir()) + await mkdir(dirname(path), { recursive: true }) + const db = new Database(path) + try { + db.pragma('journal_mode = WAL') + db.exec(` +CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, + ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) + db.pragma('user_version = 2') + const insert = db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run(IDENTITY.sessionId, row.epoch, row.seq, row.ts, row.rowJson) + } + if (rows.length > 0) { + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) + } + } finally { + db.close() + } +} + +/** Whatever is left of the chat's per-chat directory, or a copy of it, beside it. */ +async function leftovers(): Promise { + const parent = dirname(legacyDir()) + const name = legacyDir().slice(parent.length + 1) + return existsSync(parent) ? (await readdir(parent)).filter((entry) => entry.startsWith(name)) : [] +} + +function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string { + return JSON.stringify(journal.snapshot().items.map((entry) => entry.body)) +} + +function rowCount(db: Database.Database): number { + return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total) +} + +function removeFails(): void { + vi.mocked(rmSync).mockImplementation(() => { + throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + }) +} + +async function removeWorks(): Promise { + const actual = await vi.importActual('node:fs') + vi.mocked(rmSync).mockImplementation(actual.rmSync) +} + +/** The file as it is, except that the copy's first read of rows comes back through `alter`. */ +function alteringFirstCopiedRead( + alter: (rows: SqliteRow[]) => SqliteRow[] +): (path: string) => Database.Database { + return (path) => { + const source = new Database(path, { readonly: true, fileMustExist: true }) + const prepare = source.prepare.bind(source) + let altered = false + source.prepare = (sql: string) => { + const statement = prepare(sql) + if (altered || !sql.includes('seq > ?')) { + return statement + } + const all = statement.all.bind(statement) + // Why: prepare caches statements, so this one is handed out again after it was altered. + statement.all = (...args: Parameters) => { + const rows = all(...args) + if (altered) { + return rows + } + altered = true + return alter(rows) + } + return statement + } + return source + } +} + +const losingFirstCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(1)) + +/** Every row still there and still parsing, but the reply's words changed. */ +const garblingCopiedReply = alteringFirstCopiedRead((rows) => + rows.map((row) => ({ ...row, row_json: String(row.row_json).replace('On it.', 'On in.') })) +) + +function openChat() { + return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-per-session-import-')) + clock = 1_000 +}) + +afterEach(async () => { + vi.restoreAllMocks() + await removeWorks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('importing a per-chat journal', () => { + it('copies the history verbatim on first open and deletes the file', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + + const journal = await openChat() + + expect(journal.epoch).toBe(epoch) + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect( + readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) + ).toEqual(rows) + // Verified, then deleted with its WAL files: no copy of it is kept. + expect(await leftovers()).toEqual([]) + }) + + // T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered + // rows would silently withdraw every "resume after update" offer. + it('keeps a restart offer taken before the upgrade', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. + const marker = { + sessionId: IDENTITY.sessionId, + journalCursor: { epoch, sequence: rows.length } + } as AgentSessionResumeMarker + + const journal = await openChat() + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), + now: () => clock, + enqueue: (operation) => operation() + }) + + expect(withdrawal.movedOn(marker)).toBe(false) + }) + + it('copies in batches between turns of the event loop, and publishes the chat with the last', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const turns: { published: boolean; copied: number }[] = [] + let ticking = true + const tick = (): void => { + turns.push({ + published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null, + copied: rowCount(database.db) + }) + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + + await importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + batchRows: 1 + }) + ticking = false + + // Other work ran while rows were copied, and none of it could see a partly copied chat. + expect(turns.filter((turn) => !turn.published && turn.copied > 0).length).toBeGreaterThan(0) + expect(turns.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) + }) + + // A quit between two batches: no reader sees the rows copied so far, and the next open copies the + // chat again from the start, with no duplicate or leftover row. + it('copies a chat again cleanly after a copy stopped midway', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + setImmediate(() => closeTestJournalHostDatabases()) + + await expect( + importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + batchRows: 1 + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) + const reopened = openTestJournalHostDatabase(root) + expect(rowCount(reopened.db)).toBe(1) + expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull() + expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull() + + const other = await journals.open({ + identity: { ...IDENTITY, sessionId: 'session-other' }, + stateDirectory: root + }) + await other.appendItem( + item(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const journal = await openChat() + + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(readTestJournalRows(reopened.db, IDENTITY.sessionId, epoch)).toEqual(rows) + expect(rowCount(reopened.db)).toBe( + rows.length + liveTestJournalRows(reopened.db, 'session-other').length + ) + }) + + it('copies a chat once when two opens of it import at the same time', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const input = { database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 } + + const outcomes = await Promise.all([ + importPerSessionJournal(input), + importPerSessionJournal(input) + ]) + + expect(outcomes).toEqual(['imported', 'absent']) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) + expect(rowCount(database.db)).toBe(rows.length) + }) + + // Only the copy's own batches skip the fsync: a live chat's write between them, and the publish + // that makes the batches durable, commit fully synced. + it('commits copy batches unsynced, and every other commit synced', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const synchronous = () => Number(database.db.pragma('synchronous', { simple: true })) + const transaction = database.transaction.bind(database) + const commits: number[] = [] + vi.spyOn(database, 'transaction').mockImplementation((run) => + transaction((db) => { + commits.push(synchronous()) + return run(db) + }) + ) + const between: number[] = [] + let copying = true + const tick = (): void => { + // What a live chat's append would commit under, between two copy batches. + between.push(synchronous()) + if (copying) { + setImmediate(tick) + } + } + + setImmediate(tick) + await importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + batchRows: 1 + }) + copying = false + + // 2 is FULL, 1 is NORMAL. + expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1)) + expect(commits.at(-1)).toBe(2) + expect(between.length).toBeGreaterThan(0) + expect(between.every((value) => value === 2)).toBe(true) + expect(synchronous()).toBe(2) + }) + + it('ends a copy on a turn of its own, so the open that replays it starts a new task', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + let turns = 0 + const tick = (): void => { + turns += 1 + } + + setImmediate(tick) + await importPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: IDENTITY, + legacyDirectory: legacyDir() + }) + + // One batch copies, and both verify reads are one batch each: only the final yield turns. + expect(turns).toBe(1) + }) + + it('keeps the file and refuses the chat when the copy does not read back as the file', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const input = { + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: losingFirstCopiedRow + } + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(importPerSessionJournal(input)).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + } + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect( + database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() + ).toMatchObject({ + total: 0 + }) + expect(errors).toHaveBeenCalledOnce() + // A copy that reads back whole then imports it, over what the refused ones left. + const journal = await openChat() + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(rowCount(database.db)).toBe(rows.length) + }) + + // A copy that keeps every count but not every byte is no copy: the file is all there is. + it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { + const { epoch, rows } = await historyRows() + expect(rows.filter((row) => row.rowJson.includes('On it.'))).toHaveLength(1) + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const input = { + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: garblingCopiedReply + } + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + + await expect(importPerSessionJournal(input)).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect(errors).toHaveBeenCalledOnce() + }) + + // T-R2B1: the copy committed and only the delete failed (a crash between them is the same). Rows appended since, a restart, and a + // reopen with the file still there: nothing is copied again, and nothing is lost. + it('never copies the same file again after a failed delete, and deletes it on the next open', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const journal = await openChat() + await journal.appendItem( + item(2), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after the upgrade' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + // The process exits and the database closes. + await journals.closeAll() + await removeWorks() + + const reopened = await openChat() + expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) + expect(texts(reopened)).toContain('after the upgrade') + expect(await leftovers()).toEqual([]) + }) + + // T-import-transient: a read that fails leaves the file for the next open, which imports it. + it('leaves the file in place on a failed read, and refuses the open rather than serve it empty', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + + await expect( + importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: () => { + throw io + } + }) + ).rejects.toThrow(io) + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + expect( + database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() + ).toMatchObject({ total: 0 }) + + const journal = await openChat() + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(existsSync(legacyDir())).toBe(false) + }) + + // A file that is not a database stays where it is, and the chat says it cannot be loaded. + it('refuses the open of a legacy file that is not a database, and keeps the file', async () => { + await mkdir(legacyDir(), { recursive: true }) + await writeFile(legacyJournalDatabaseFile(legacyDir()), 'not a database '.repeat(512)) + + await expect(openChat()).rejects.toMatchObject({ errcode: 26 }) + + expect(await readdir(legacyDir())).toEqual(['journal.db']) + }) + + it('leaves a never-written file in place until the chat it belongs to is founded', async () => { + await writeLegacyJournal('unused', []) + await mkdir(legacyDir(), { recursive: true }) + await writeFile(join(legacyDir(), 'log.jsonl'), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') + + const journal = await openChat() + + // The pre-SQLite transcript beside it is still there to explain the empty chat. + expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl') + await journals.closeAll() + await openChat() + // The journal file goes; the transcript is the user's, and stays. + expect(await readdir(legacyDir())).toEqual(['log.jsonl']) + }) + + // A crash between creating the file and giving it the schema: the chat opens with no history, as + // it did when each chat opened its own file, and the file is deleted like any never-written one. + it.each([ + ['empty', async (path: string) => writeFile(path, '')], + [ + 'schema-less', + async (path: string) => { + const db = new Database(path) + db.pragma('journal_mode = WAL') + db.close() + } + ] + ])('opens a chat whose per-chat file is %s as having no history', async (_fileState, create) => { + await mkdir(legacyDir(), { recursive: true }) + await create(legacyJournalDatabaseFile(legacyDir())) + + const journal = await openChat() + + expect(journal.snapshot().items).toEqual([]) + await journals.closeAll() + await openChat() + expect(await leftovers()).toEqual([]) + }) + + // F-L6-1: the copy deleted the file, so the older build found none and started the chat over. + // The re-upgrade must neither let that start replace the history nor delete what it wrote. + it('keeps the history, and the older build’s file untouched, after a downgrade round trip', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + expect(texts(await openChat())).toContain('ORIGINAL HISTORY') + await journals.closeAll() + expect(existsSync(legacyDir())).toBe(false) + const older = await historyRows('epoch-older-fresh', 'typed in the older build') + await writeLegacyJournal(older.epoch, older.rows) + const file = legacyJournalDatabaseFile(legacyDir()) + const bytes = await readFile(file) + + for (let open = 0; open < 2; open += 1) { + const reopened = await openChat() + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).not.toContain('typed in the older build') + await journals.closeAll() + } + const database = openTestJournalHostDatabase(root) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, first.epoch)).toEqual(first.rows) + expect((await readFile(file)).equals(bytes)).toBe(true) + const kept = new Database(file, { readonly: true }) + const keptRows = kept.prepare('SELECT seq, ts, row_json FROM journal_rows ORDER BY seq').all() + kept.close() + expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson)) + }) + + // The older build started over and then rewound, so its file's epoch opens `handle_forked`: it + // still never held this build's history, and is set aside like any other epoch. + it('keeps the history when the older build rewound the chat it started over', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + await openChat() + await journals.closeAll() + const older = await historyRows('epoch-older-rewound', 'typed in the older build') + const opening = JSON.parse(older.rows[0]!.rowJson) + expect(opening).toMatchObject({ kind: 'epoch', reason: 'session_created' }) + const rewound = [ + { ...older.rows[0]!, rowJson: JSON.stringify({ ...opening, reason: 'handle_forked' }) }, + ...older.rows.slice(1) + ] + await writeLegacyJournal(older.epoch, rewound) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + + const reopened = await openChat() + + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).not.toContain('typed in the older build') + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + // The one file at another epoch that did descend from the copy: its delete failed and the older + // build rolled the epoch of the file it kept. It is set aside too; nothing this build has is lost. + it('sets aside a kept file the older build rolled to a new epoch', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openChat() + await journals.closeAll() + await removeWorks() + const rolled = await historyRows('epoch-rolled-by-older', 'rolled in the older build') + await rm(legacyDir(), { recursive: true, force: true }) + await writeLegacyJournal(rolled.epoch, rolled.rows) + + const reopened = await openChat() + + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).not.toContain('rolled in the older build') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + }) + + // A chat this build founded has a pointer and no import marker: an older build that then starts + // a per-chat file for it never held this build's history, so the file is set aside. + it('keeps a chat founded in this build when an older build starts a per-chat file for it', async () => { + const founded = await openChat() + await founded.appendItem( + item(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'FOUNDED HERE' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const foundedEpoch = founded.epoch + await journals.closeAll() + const older = await historyRows('epoch-older-fresh', 'typed in the older build') + await writeLegacyJournal(older.epoch, older.rows) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + + const reopened = await openChat() + + expect(reopened.epoch).toBe(foundedEpoch) + expect(texts(reopened)).toContain('FOUNDED HERE') + expect(texts(reopened)).not.toContain('typed in the older build') + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + // Decided once and recorded: no later open reads the file again, after a restart or after the + // older build ran again and wrote more to it. + it('never reads a set-aside file again', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + await openChat() + await journals.closeAll() + const older = await historyRows('epoch-older-fresh', 'typed in the older build') + await writeLegacyJournal(older.epoch, older.rows) + const reads: string[] = [] + const countingSource = (path: string) => { + reads.push(path) + return new Database(path, { readonly: true, fileMustExist: true }) + } + const importAgain = () => + importPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: countingSource + }) + + expect(await importAgain()).toBe('kept') + expect(reads).toHaveLength(1) + closeTestJournalHostDatabases() + expect(await importAgain()).toBe('kept') + const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } + const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) + legacy + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) + legacy.close() + expect(await importAgain()).toBe('kept') + + expect(reads).toHaveLength(1) + expect(texts(await openChat())).toContain('ORIGINAL HISTORY') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + }) + + // T-B5: a downgrade, an older build starting the chat over in a new per-chat file, and a + // re-upgrade — twice. This build's history stays whole each time, and the file stays on disk. + it('keeps this build’s history on every re-upgrade after an older build started the chat over', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + const upgraded = await openChat() + await upgraded.appendItem( + item(2), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journals.closeAll() + const older = await historyRows('epoch-downgrade', 'older build, cycle 1') + await writeLegacyJournal(older.epoch, older.rows) + + for (const cycle of [1, 2]) { + if (cycle === 2) { + // The second downgrade finds the file it wrote the first time, and carries it on. + const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } + row.body = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'cycle 2' }] + } + const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) + legacy + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) + legacy.close() + } + const reopened = await openChat() + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).toContain('this build') + expect(texts(reopened)).not.toContain('older build, cycle 1') + expect(texts(reopened)).not.toContain('cycle 2') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + await journals.closeAll() + } + }) + + // The delete failed, so the older build found the copied file and carried its epoch on, while + // this build wrote nothing past the copy. This build's history still wins: the file is set aside. + it('sets aside a file an older build carried on under the copied epoch', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openChat() + await journals.closeAll() + appendOlderRow(epoch, rows, rows.length + 1) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + await removeWorks() + + const reopened = await openChat() + + expect(reopened.epoch).toBe(epoch) + expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(texts(reopened)).not.toContain('older') + expect(sharedRowsContaining('older')).toBe(0) + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + /** The older build appends one row to the per-chat file the failed delete left, at its epoch. */ + function appendOlderRow(epoch: string, rows: readonly JournalStoredRow[], seq: number): void { + const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq } + olderRow.body = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'older' }] + } + olderRow.itemId = `${olderRow.itemId}-older` + const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) + legacy + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow)) + legacy.close() + } + + function sharedRowsContaining(text: string): number { + return openTestJournalHostDatabase(root) + .db.prepare('SELECT row_json FROM journal_rows') + .all() + .filter((row) => String(row.row_json).includes(text)).length + } +}) diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts new file mode 100644 index 00000000000..5f85fdded43 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts @@ -0,0 +1,302 @@ +// Copying a chat's per-chat journal file into the host's one database, on that chat's open. +// +// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. This reads Orca's own +// earlier `/journal.db`, verbatim: the same epoch UUID and every sequence number, so a +// cursor, an `acceptedSequence` or a restart offer taken before the upgrade still points at the +// same row after it. A file that reappears after a downgrade is set aside, never read again (see +// journal-per-session-reimport.ts). +// +// The copy runs in bounded batches, each its own transaction, yielding the event loop between them. +// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader +// sees them. Once they read back as the file does (every row's sequence, time and bytes), one +// transaction publishes the chat's pointer with its repair and import markers, so the chat is +// imported all at once or not at all. A try that stops midway leaves only unpublished rows, which +// the next try deletes before it copies again. A copy that does not read back as the file is never +// published: the file stays, and the chat is refused as unreadable. +// +// Only after that commit is the file deleted, its connection closed first. A read that fails +// leaves the file where it is for the next open, and the open is refused rather than served empty: +// an empty chat founded here would take a new epoch the next open's import could not reconcile. + +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' +import type { JournalLoad } from './journal-open' +import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure' +import { legacyJournalDatabaseFile } from './journal-paths' +import { + planPerSessionImport, + isPerSessionJournalSetAside, + readPerSessionImportMarker, + setAsidePerSessionJournal, + writePerSessionImportMarker, + type PerSessionImportPlan, + type PerSessionJournalHead +} from './journal-per-session-reimport' +import { + foldLegacyJournal, + IMPORT_BATCH_ROWS, + legacyRowBatches, + openLegacySource, + readLegacyHead, + readLegacyRepair, + retireLegacyJournal, + type ImportBatch +} from './journal-per-session-source' +import { parseJournalRow } from './journal-row-schema' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { + deleteUnpublishedJournalRows, + publishJournalSessionEpoch, + readJournalRowsAfter, + readJournalSessionEpoch +} from './journal-row-table' + +const INSERT_ROW = + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' +const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) +VALUES (?, ?, ?, ?) +ON CONFLICT(session_id) DO UPDATE SET + epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` + +type PerSessionJournalImportDeps = { + openSource?: (path: string) => Database.Database + /** Deletes one of the per-chat files. */ + remove?: (path: string) => void + batchRows?: number +} + +export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept' + +type ImportInput = { + database: JournalHostDatabase + identity: AgentSessionJournalIdentity + legacyDirectory: string +} & PerSessionJournalImportDeps + +/** Imports in flight, by database and chat: a second open of the same chat waits for the first. */ +const importsInFlight = new WeakMap>>() + +export function importPerSessionJournal( + input: ImportInput +): Promise { + let inFlight = importsInFlight.get(input.database) + if (!inFlight) { + inFlight = new Map() + importsInFlight.set(input.database, inFlight) + } + const { sessionId } = input.identity + const run = (inFlight.get(sessionId) ?? Promise.resolve()).then(() => importOnce(input)) + const settled = run.catch(() => undefined) + inFlight.set(sessionId, settled) + void settled.then(() => { + if (inFlight.get(sessionId) === settled) { + inFlight.delete(sessionId) + } + }) + return run +} + +async function importOnce(input: ImportInput): Promise { + const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) + if (!existsSync(sourcePath)) { + return 'absent' + } + const { sessionId } = input.identity + if (isPerSessionJournalSetAside(input.database.db, sessionId)) { + return 'kept' + } + const published = readJournalSessionEpoch(input.database.db, sessionId) !== null + const source = (input.openSource ?? openLegacySource)(sourcePath) + let legacy: PerSessionJournalHead | null + let plan: PerSessionImportPlan | null = null + try { + legacy = readLegacyHead(source, sessionId) + if (legacy) { + plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, published }) + if (plan.kind === 'first') { + await copyLegacyJournal(input, source, legacy) + } + } + } finally { + source.close() + } + if (!legacy) { + // Never written. Left in place while its chat is unfounded: that open's empty chat may still + // owe the notice about a pre-SQLite transcript beside it. + if (!published) { + return 'absent' + } + retireLegacyJournal(input.legacyDirectory, input.remove) + return 'already-imported' + } + if (plan?.kind === 'kept') { + setAsidePerSessionJournal(input.database.db, sessionId, legacy) + return 'kept' + } + // Also a file a crash left after its copy was recorded (`copied`): deleted now, not copied again. + retireLegacyJournal(input.legacyDirectory, input.remove) + if (plan?.kind === 'copied') { + return 'already-imported' + } + // The open's replay of what was just copied is a long task of its own; don't add this one to it. + await yieldToEventLoop() + return 'imported' +} + +/** + * The chat a first copy would import, folded straight from its per-chat file and copying nothing: + * for a restore, which must not import. Null when the open has to import now instead: the chat is + * already in the host's database or was copied before (the reimport rules decide), its file holds + * no chat, or its fold needs a repair written. The file is closed before this returns. + */ +export async function previewPerSessionJournal( + input: Pick +): Promise { + const { sessionId } = input.identity + const db = input.database.db + const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) + if ( + readJournalSessionEpoch(db, sessionId) !== null || + readPerSessionImportMarker(db, sessionId) || + !existsSync(sourcePath) + ) { + return null + } + const source = (input.openSource ?? openLegacySource)(sourcePath) + try { + const legacy = readLegacyHead(source, sessionId) + if (!legacy) { + return null + } + const loaded = await foldLegacyJournal(source, sessionId, legacy) + return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded + } finally { + source.close() + } +} + +/** + * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Once + * the rows read back as the file does, one transaction publishes the pointer with the chat's repair + * marker and the import marker. + */ +async function copyLegacyJournal( + input: ImportInput, + source: Database.Database, + legacy: PerSessionJournalHead +): Promise { + const { sessionId } = input.identity + const { epoch } = legacy + const repair = readLegacyRepair(source, sessionId) + const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + let first = true + for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { + if (!first) { + await yieldToEventLoop() + } + // Unsynced: no reader follows these rows, and the publish's synced commit covers them. + input.database.unsyncedTransaction((db) => { + if (first) { + // What an earlier try that stopped midway left. + deleteUnpublishedJournalRows(db, sessionId) + } + const insert = db.prepare(INSERT_ROW) + for (const row of batch.rows) { + // Copied as stored: the bytes are the row, its epoch and sequence included. + insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson) + } + }) + first = false + } + await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) + input.database.transaction((db) => { + publishJournalSessionEpoch(db, input.identity, epoch) + if (repair) { + db.prepare(UPSERT_REPAIR).run( + sessionId, + repair.epoch, + repair.content_from, + repair.repaired_at + ) + } + writePerSessionImportMarker(db, sessionId, legacy) + }) +} + +/** Mismatches already logged, so a chat refused on every open logs once. */ +const loggedMismatches = new Set() + +/** + * The copied rows, read back from the host's database, against a second read of what was copied: + * the same rows, byte for byte, and the same epoch, tip, row count, items and submissions, or the + * copy is refused and never published. Both reads go a batch at a time, so no check holds the main + * thread longer than a copy batch does. + */ +async function verifyCopiedJournal( + input: ImportInput, + expected: Iterable, + epoch: string +): Promise { + const { sessionId } = input.identity + const want = await copyFacts(sessionId, expected) + const got = await copyFacts(sessionId, copiedBatches(input, epoch)) + if (want === got) { + return + } + const error = new JournalImportMismatchError( + `per-chat journal of ${sessionId} read back as ${got} after its copy, not ${want}` + ) + if (!loggedMismatches.has(`${sessionId}\n${want}\n${got}`)) { + loggedMismatches.add(`${sessionId}\n${want}\n${got}`) + console.error(`[agent-session-journal] ${error.message}; ${input.legacyDirectory} is kept`) + } + throw journalOpenRefusalError(error) +} + +/** Epoch, tip, row count, items, submissions and a digest of every row, folded a batch at a time. */ +async function copyFacts(sessionId: string, batches: Iterable): Promise { + const state = createJournalReducerState(sessionId, '') + const content = createHash('sha256') + let epoch: string | null = null + let tip = 0 + let rows = 0 + let first = true + for (const batch of batches) { + if (!first) { + await yieldToEventLoop() + } + first = false + rows += batch.rows.length + for (const row of batch.rows) { + tip = Math.max(tip, row.seq) + // Length-framed, so no two different rows hash the same stream. + content.update(`${row.seq}:${row.ts}:${row.rowJson.length}:`).update(row.rowJson) + const parsed = parseJournalRow(row.rowJson) + if (parsed.ok) { + epoch ??= parsed.row.epoch + applyJournalRow(state, parsed.row) + } + } + } + return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` +} + +function* copiedBatches(input: ImportInput, epoch: string): Generator { + const { sessionId } = input.identity + const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const rows = readJournalRowsAfter(input.database.db, sessionId, epoch, afterSeq, batchRows) + const lastSeq = rows.at(-1)?.seq + const last = rows.length < batchRows || lastSeq === undefined + yield { rows, last } + if (last) { + return + } + afterSeq = lastSeq + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts new file mode 100644 index 00000000000..61ddbfc98f1 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts @@ -0,0 +1,73 @@ +// A per-chat file that reappears after its chat was copied in: an older build, run after a +// downgrade, attached the chat and wrote its history there. +// +// `journal_imports` records which file each chat was copied from — its epoch and tip — in the +// transaction that publishes the verified copy. A file still at that epoch and tip was already +// copied (only its delete failed, or a crash came first, across any number of restarts): it is +// deleted, never copied again. This build's history always wins: once a chat has been copied or +// founded here, any other file is set aside, left on disk as it is, and recorded in +// `journal_set_aside`, so no later open reads it again. + +import type Database from '../../sqlite/sync-database' + +export type PerSessionJournalHead = { epoch: string; tip: number } + +const SELECT_MARKER = 'SELECT epoch, tip FROM journal_imports WHERE session_id = ?' +const UPSERT_MARKER = `INSERT INTO journal_imports (session_id, epoch, tip) VALUES (?, ?, ?) +ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, tip = excluded.tip` + +export function readPerSessionImportMarker( + db: Database.Database, + sessionId: string +): PerSessionJournalHead | null { + const row = db.prepare(SELECT_MARKER).get(sessionId) + return typeof row?.epoch === 'string' && typeof row.tip === 'number' + ? { epoch: row.epoch, tip: row.tip } + : null +} + +const SELECT_SET_ASIDE = 'SELECT 1 AS present FROM journal_set_aside WHERE session_id = ?' +const INSERT_SET_ASIDE = `INSERT INTO journal_set_aside (session_id, epoch, tip) VALUES (?, ?, ?) +ON CONFLICT(session_id) DO NOTHING` + +export function isPerSessionJournalSetAside(db: Database.Database, sessionId: string): boolean { + return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined +} + +/** Records a file that is not this build's history, as it was when set aside. */ +export function setAsidePerSessionJournal( + db: Database.Database, + sessionId: string, + head: PerSessionJournalHead +): void { + db.prepare(INSERT_SET_ASIDE).run(sessionId, head.epoch, head.tip) +} + +export function writePerSessionImportMarker( + db: Database.Database, + sessionId: string, + head: PerSessionJournalHead +): void { + db.prepare(UPSERT_MARKER).run(sessionId, head.epoch, head.tip) +} + +export type PerSessionImportPlan = + | { kind: 'first' } + | { kind: 'copied' } + /** Not this build's history: set aside, neither copied nor deleted. */ + | { kind: 'kept' } + +/** What a present per-chat file owes this chat, judged against what was last copied from it. */ +export function planPerSessionImport(input: { + db: Database.Database + sessionId: string + legacy: PerSessionJournalHead + /** The chat already has an epoch in the host's database. */ + published: boolean +}): PerSessionImportPlan { + const marker = readPerSessionImportMarker(input.db, input.sessionId) + if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) { + return { kind: 'copied' } + } + return !marker && !input.published ? { kind: 'first' } : { kind: 'kept' } +} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts new file mode 100644 index 00000000000..dc548e2f3dd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts @@ -0,0 +1,144 @@ +// Reading a chat's per-chat journal file, the one each chat had before the host's one database. +// The importer copies through this reader, and a restore folds through it without copying. + +import { rmdirSync, rmSync } from 'node:fs' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import Database from '../../sqlite/sync-database' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import { startJournalRowFold, type JournalLoad } from './journal-open' +import { legacyJournalDatabaseFile } from './journal-paths' +import type { PerSessionJournalHead } from './journal-per-session-reimport' +import { pendingJournalRepairSequence } from './journal-repair-marker' + +/** The newest per-chat file shape any build wrote. */ +const LEGACY_JOURNAL_SCHEMA_VERSION = 2 +/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */ +export const IMPORT_BATCH_ROWS = 512 + +const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' +const SELECT_LEGACY_TIP = + 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' +const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows +WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` +const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" +const SELECT_LEGACY_REPAIR = + 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' + +type ImportedRow = { seq: number; ts: number; rowJson: string } +export type ImportBatch = { rows: ImportedRow[]; last: boolean } + +/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ +export function openLegacySource(path: string): Database.Database { + const source = new Database(path, { readonly: true, fileMustExist: true }) + try { + const version = Number(source.pragma('user_version', { simple: true }) ?? 0) + if (version > LEGACY_JOURNAL_SCHEMA_VERSION) { + throw new Error(`per-chat journal ${path} uses schema ${version}, which no build wrote`) + } + return source + } catch (error) { + source.close() + throw error + } +} + +export function readLegacyHead( + source: Database.Database, + sessionId: string +): PerSessionJournalHead | null { + // Created but never given its schema (a crash between the two): no history, as an empty file. + if (!source.prepare(HAS_LEGACY_TABLE).get('journal_sessions')) { + return null + } + const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch + if (typeof epoch !== 'string' || epoch.length === 0) { + return null + } + const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip + return { epoch, tip: typeof tip === 'number' ? tip : 0 } +} + +/** The file's rows, one bounded page per batch, read as each batch is written. */ +export function* legacyRowBatches( + source: Database.Database, + sessionId: string, + epoch: string, + batchRows: number +): Generator { + const select = source.prepare(SELECT_LEGACY_ROWS) + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const rows = select + .all(sessionId, epoch, afterSeq, batchRows) + .map((row) => ({ seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) })) + const lastSeq = rows.at(-1)?.seq + const last = rows.length < batchRows || lastSeq === undefined + yield { rows, last } + if (last) { + return + } + afterSeq = lastSeq + } +} + +/** The per-chat repair marker, as stored; v1 files predate the table. */ +export function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { + if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) { + return null + } + return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null +} + +/** The chat as its file holds it, folded the way a replay folds the host's database. */ +export async function foldLegacyJournal( + source: Database.Database, + sessionId: string, + legacy: PerSessionJournalHead +): Promise { + const fold = startJournalRowFold({ + sessionId, + epoch: legacy.epoch, + repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') + ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) + : null + }) + let first = true + for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { + // A batch per turn: a large chat's file read in one task holds up everything else at startup. + if (!first) { + await yieldToEventLoop() + } + first = false + if (!batch.rows.every(fold.add)) { + break + } + } + return fold.finish() +} + +/** + * Deletes the per-chat file and SQLite's WAL files beside it, then the directory if nothing else is + * in it: a pre-SQLite transcript there is the user's, and stays. Its connection must be closed. + * Best effort: the copy is committed, so a file left behind is deleted by the next open. + */ +export function retireLegacyJournal( + legacyDirectory: string, + remove: (path: string) => void = (path) => rmSync(path, { force: true }) +): void { + const file = legacyJournalDatabaseFile(legacyDirectory) + try { + // The database first: a WAL left without it is never read, but a database left without its WAL + // would read back short of the tip it was copied at, and be set aside rather than deleted. + for (const path of [file, `${file}-wal`, `${file}-shm`]) { + remove(path) + } + } catch (error) { + console.warn(`[agent-session-journal] deleting imported ${legacyDirectory} failed`, error) + return + } + try { + rmdirSync(legacyDirectory) + } catch { + // Not empty: something beside the journal is kept. + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index 07ae011b790..d41e9b363ed 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -14,7 +15,7 @@ import { buildJournalTombstoneRow, journalLifecycleBatchRowBuilder } from './journal-row-builders' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' // A row's producer is fixed by the write that created it. A revision naming no // producer — a settlement, a prompt answer, a reopen sweep — keeps it; a @@ -79,7 +80,8 @@ function seeded() { seq, fence: 1, ts: 1_000 + seq, - ...(stamp ? { linkage: stamp } : {}) + ...(stamp ? { linkage: stamp } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ) } @@ -90,7 +92,12 @@ function seeded() { journalLifecycleBatchRowBuilder( () => state, `settle-${seq}`, - identities.map((identity) => ({ kind: 'item' as const, identity, body: text('settled') })), + identities.map((identity) => ({ + kind: 'item' as const, + identity, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), { fence: 1 } )(seq, 1_000 + seq) ) @@ -165,7 +172,7 @@ describe('producer inheritance across a reopen', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'root' } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) @@ -180,15 +187,35 @@ describe('producer inheritance across a reopen', () => { it('replays an inherited producer from disk exactly as it was folded live', async () => { const journal = await open() - await journal.appendItem(child, text('working'), { fence: 1, ...linkage }) - await journal.appendItem(own, text('working'), { fence: 1 }) - await journal.appendItem(child, text('still working'), { fence: 1 }) + await journal.appendItem(child, text('working'), { + fence: 1, + ...linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(own, text('working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(child, text('still working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendLifecycleBatch({ settlementId: 'settle-1', fence: 1, mutations: [ - { kind: 'item', identity: child, body: text('settled') }, - { kind: 'item', identity: own, body: text('settled') } + { + kind: 'item', + identity: child, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: own, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] }) const live = journal.snapshot().items diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts new file mode 100644 index 00000000000..96b45c9d17c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -0,0 +1,363 @@ +// The journal's draft-store collaborator: every read and write of one session's +// `queued_messages` rows, serialized on the same queue as the journal's own +// appends so a draft mutation can never interleave with the consume that +// converts it. Drafts are NEVER owed work: nothing here feeds the reducer, +// working status, teardown, or the idle sweep. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../shared/agent-session-host-authority' +import type { JournalHostDatabase } from './journal-host-database' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import type { JournalSubmissionConsume } from './journal-store-contracts' +import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { + clearQueuePause, + queuePauseHoldsBack, + readQueuePause, + recordQueuePause, + retireQueuePauseIfNothingHeld, + type QueuePauseFact, + type QueuePauseReason +} from './queued-message-pause-table' +import { + consumeQueuedMessageInTransaction, + getQueuedMessage, + insertQueuedMessage, + listQueuedMessages, + queuedMessagesSettledByOp, + withdrawQueuedMessages, + type QueuedMessageHoldReason, + type QueuedMessageRow +} from './queued-message-table' +import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' +import { + owedBackToWaiting, + queuedMessageSettlementOwed, + settleOwedQueuedMessages, + settleQueuedMessagesForRow +} from './queued-message-settlement' +import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' + +/** Tombstones must outlive the window in which their operation id could still be admitted as new. */ +export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + +export type JournalQueuedMessagesDeps = { + sessionId: string + now: () => number + serialize: (run: () => Promise) => Promise + database: () => JournalHostDatabase + readOnly: () => boolean + state: () => JournalReducerState + /** The journal's own commit notification. Every standalone draft-table + * transaction that changed rows fires it after COMMIT, so a draft or hold + * change publishes and wakes the drain through the same path a journal row + * does — no call site can forget. In-transaction consume and the returned + * transition already ride their row's own commit. */ + committed: () => void +} + +export class JournalQueuedMessages { + /** Bumped on every draft-table write, so publication memos recompute only when they must. */ + private changeRevision = 0 + private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null + private paused: { revision: number; fact: QueuePauseFact | null } | null = null + + constructor(private readonly deps: JournalQueuedMessagesDeps) {} + + revision(): number { + return this.changeRevision + } + + /** The submission row of the latest accepted turn a person asked for; 0 when none. What + * ends the queue's pause, read from the reducer in O(1). */ + latestPersonTurnSequence(): number { + return this.deps.state().latestPersonTurnSequence + } + + /** A journal transaction rolled back: nothing read inside it may stay cached. */ + invalidate(): void { + this.changeRevision++ + } + + /** Cached per revision: the drain re-checks on every journal publish, so an + * unchanged table must cost no SQL read or body parse on token streams. */ + list(): readonly QueuedMessageRow[] { + if (this.listed?.revision !== this.changeRevision) { + this.listed = { + revision: this.changeRevision, + rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) + } + } + return this.listed.rows + } + + get(messageId: string): QueuedMessageRow | null { + return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) + } + + /** Replay receipts for one caller-scoped operation key. */ + receipts(settledByOp: string): QueuedMessageRow[] { + return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) + } + + /** `pausedBy`: the queue is paused in the SAME transaction as this card lands + * (a /clear's carry), so the drain never sees it unpaused and no pause fact + * exists without a card under it. */ + insert(input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + pausedBy?: QueuePauseReason + }): Promise { + const { pausedBy, ...draft } = input + const { sessionId } = this.deps + let inserted = false + return this.transact( + (db) => { + const existing = getQueuedMessage(db, sessionId, draft.messageId) + if (existing) { + // One id, one draft: admission replays a recorded operation before it + // gets here, so an existing row is the same accept landing twice. + return existing + } + inserted = true + const row = insertQueuedMessage(db, { ...draft, sessionId, now: this.deps.now() }) + if (pausedBy) { + recordQueuePause(db, { sessionId, fact: this.pauseFact(pausedBy) }) + } + return row + }, + () => inserted + ) + } + + /** Hold one waiting draft whose conversion failed. Stored on the row, so it + * survives handle eviction and restart; withdraw and consume clear it in their + * own UPDATE. */ + hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise { + return this.transact( + (db) => holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }), + (held) => held > 0 + ).then(() => undefined) + } + + /** Where the user's last Stop took effect, if it is still recorded; cached per revision. */ + pause(): QueuePauseFact | null { + if (this.paused?.revision !== this.changeRevision) { + this.paused = { + revision: this.changeRevision, + fact: readQueuePause(this.deps.database().db, this.deps.sessionId) + } + } + return this.paused.fact + } + + /** A Stop took effect here: the queue is paused from this position on — if, judged in + * the same transaction, it holds back a card at all. Returns whether it recorded. */ + recordPause(reason: QueuePauseReason): Promise { + const fact = this.pauseFact(reason) + return this.transact( + (db) => + queuePauseHoldsBack(db, this.pauseScope()) && + (recordQueuePause(db, { sessionId: this.deps.sessionId, fact }), true), + (recorded) => recorded + ) + } + + /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ + private pauseScope() { + return { + sessionId: this.deps.sessionId, + owedToWaiting: owedBackToWaiting(this.deps.state().submissions) + } + } + + private pauseFact(reason: QueuePauseReason): QueuePauseFact { + const { epoch, lastSequence: sequence } = this.deps.state() + return { reason, epoch, sequence, recordedAt: this.deps.now() } + } + + /** Ends the queue's pause: `stop` retires that Stop fact (never a later one), + * `adoptInto` adopts a restart's rows into this host instance. Returns whether + * anything changed. */ + liftPause(input: { stop: QueuePauseFact | null; adoptInto: string | null }): Promise { + const { sessionId } = this.deps + return this.transact( + (db) => + (input.stop ? clearQueuePause(db, { sessionId, fact: input.stop }) : 0) + + (input.adoptInto === null + ? 0 + : adoptQueuedMessages(db, { sessionId, hostInstance: input.adoptInto })), + (changed) => changed > 0 + ).then((changed) => changed > 0) + } + + /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, + * kept only so a replay of the settling operation answers "spent". */ + withdraw(input: { + messageIds: readonly string[] + settledByOp: string + }): Promise { + if (input.messageIds.length === 0) { + // Delete races and empty carries land here; neither may cost a write transaction. + return Promise.resolve([]) + } + return this.transact( + (db) => + withdrawQueuedMessages(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }), + (withdrawn) => withdrawn.length > 0 + ) + } + + /** One standalone draft-table transaction on the journal's queue; one that + * changed rows bumps the revision and notifies after COMMIT. */ + private transact( + run: (db: Database.Database) => T, + changed: (result: T) => boolean + ): Promise { + return this.deps.serialize(async () => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const { result, retired } = this.deps.database().transaction((db) => ({ + result: run(db), + // Any draft write may take the last card a pause holds back. + retired: retireQueuePauseIfNothingHeld(db, this.pauseScope()) + })) + if (changed(result) || retired > 0) { + this.changeRevision++ + this.deps.committed() + } + return result + }) + } + + /** The standing writer hook, within the append's transaction + * (`settleQueuedMessagesForRow`). */ + onRowInTransaction(db: Database.Database, row: JournalRow): void { + this.changeRevision += settleQueuedMessagesForRow(db, { + sessionId: this.deps.sessionId, + state: this.deps.state(), + drafts: () => this.list(), + row, + now: this.deps.now() + }) + this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope()) + } + + /** The in-transaction consume for `appendSubmission`; a false compare-and-set + * throws so the whole append — draft transition AND submission row — rolls back. */ + consumeInTransaction( + db: Database.Database, + input: JournalSubmissionConsume & { consumedAs: string } + ): void { + const { db: own } = this.deps.database() + if (own !== db) { + // Same handle only: a second connection could not join the transaction. + throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') + } + const consumed = consumeQueuedMessageInTransaction(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }) + if (!consumed) { + throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + } + retireQueuePauseIfNothingHeld(db, this.pauseScope()) + this.changeRevision++ + } + + /** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */ + settlementOwed(): boolean { + return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions) + } + + /** Waiting drafts a skipped echo hook left unwithdrawn; reads every item, so only the drain + * step asks, right before a draft would send. */ + deliveredByEchoOwed(): boolean { + return draftsDeliveredByAppliedEcho(this.deps.state(), this.list()).length > 0 + } + + /** Applies owed settlements now, so a skipped live transition heals without a reopen. */ + settleOwed(): Promise { + return this.transact( + (db) => + settleOwedQueuedMessages(db, { + sessionId: this.deps.sessionId, + state: this.deps.state(), + now: this.deps.now() + }), + (settled) => settled > 0 + ).then(() => undefined) + } + + /** Bookkeeping at open: a failure is reported and retried at the next open, + * never allowed to fail opening the chat. */ + repairAndPruneAtOpen(): Promise { + return this.repairAndPrune().catch((error: unknown) => { + console.warn('[journal-open] queued-message repair skipped:', { + sessionId: this.deps.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + }) + } + + /** + * Open-time reconciliation, a re-derivation behind the stored fact: owed + * settlements apply exactly as the live hook would have (covers consume → + * crash → downgrade → upgrade, where the old build rejected the leftover with + * no hook), then retention runs; a pause left holding back nothing retires. + */ + repairAndPrune(): Promise { + // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. + if (this.deps.readOnly() || (this.list().length === 0 && this.pause() === null)) { + return Promise.resolve() + } + const { sessionId } = this.deps + return this.transact( + (db) => { + const [now, state] = [this.deps.now(), this.deps.state()] + return ( + settleOwedQueuedMessages(db, { sessionId, state, now }) + + pruneQueuedMessages(db, { + sessionId, + now, + replayWindowMs: QUEUED_MESSAGE_REPLAY_WINDOW_MS, + submissionVerdict: retainedSubmissionVerdict(state.submissions) + }) + ) + }, + (changed) => changed > 0 + ).then(() => undefined) + } +} + +/** The per-append hook converting one draft inside the append's own transaction. */ +export function queuedMessageConsumeHook( + queuedMessages: JournalQueuedMessages, + consumedAs: string, + consume: JournalSubmissionConsume +): (db: Database.Database) => void { + return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) +} + +export class QueuedMessageNotConsumableError extends Error { + constructor( + readonly messageId: string, + readonly expected: 'waiting' | 'returned' + ) { + super(`queued message ${messageId} is no longer ${expected}`) + this.name = 'QueuedMessageNotConsumableError' + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts new file mode 100644 index 00000000000..6c3640c15cb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalItemRow, journalLifecycleBatchRowBuilder } from './journal-row-builders' + +const EPOCH = 'epoch-1' + +function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +describe('producer linkage round-trips through the reducer', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'child-1' + } + const linkage = { + agentId: 'task-1', + parentAgentId: 'task-parent', + providerParentRef: 'toolu_1', + producerKind: 'agent' as const, + attempt: 2 + } + + it('copies the whole bundle onto the render item on the plain item path', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], + ...base(1), + ...linkage + }) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('reads each mutation of a mixed batch as its own producer', () => { + // A batch can CREATE rows several agents produced — a settlement landing + // before any checkpoint did. The mutation that names a producer is that + // producer's; the one naming none is the session's own, beside it. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + journalLifecycleBatchRowBuilder( + () => state, + 'settle-mixed', + [ + { + kind: 'item', + identity, + body: text('child'), + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, + body: text('own'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { fence: 1 } + )(1, 1_001) + ) + + const [child, own] = renderJournalState(state).items + expect(child).toMatchObject({ body: text('child'), ...linkage }) + expect(own?.body).toEqual(text('own')) + expect(own && 'agentId' in own).toBe(false) + }) + + it('lets a correction win over the provisional row, without moving the bubble', () => { + // Write-through then correct: the row is written under the spawn call's own + // id, then re-appended under the canonical one. Revision is assigned inside + // the journal's serialized write step, so the later append always outranks + // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does + // not relocate it in the timeline. + const state = createJournalReducerState('session-1', EPOCH) + const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage: provisional, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 9, + fence: 1, + ts: 9_999, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) + }) + + it('does not let a stale checkpoint undo a correction that already landed', () => { + // A text checkpoint carrying the OLD stamp, submitted after the correction, + // would re-root the row. It cannot: revision is read at write time, so the + // last write wins and the lane resolves linkage fresh on every checkpoint. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a'), + seq: 1, + fence: 1, + ts: 1, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a and more'), + seq: 2, + fence: 1, + ts: 2, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + const items = renderJournalState(state).items + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it('keeps linkage when a later revision rewrites the row', () => { + // The resolved-append path lost the marker once before by rebuilding the + // row without it, so the SECOND write is the one that matters here. + const state = createJournalReducerState('session-1', EPOCH) + for (const [seq, body] of [ + [1, text('look')], + [2, text('looking at the lane')] + ] as const) { + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it("renders a row that predates linkage as the session's own", () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'item', + itemId: 'i-legacy', + revision: 1, + body: text('written before linkage existed'), + ...base(1) + }) + const item = renderJournalState(state).items[0] + expect(item && 'agentId' in item).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index eea740e754d..45681f32871 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey, @@ -17,11 +18,7 @@ import { renderJournalState, type JournalReducerState } from './journal-reducer' -import { - buildJournalItemRow, - buildJournalTombstoneRow, - journalLifecycleBatchRowBuilder -} from './journal-row-builders' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -657,7 +654,15 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) expect(renderJournalState(state).items).toEqual([]) @@ -666,7 +671,15 @@ describe('re-adding a tombstoned row', () => { // `items` would restart at 1 and lose to the tombstone forever. applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')]) }) @@ -682,12 +695,28 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(state.tombstones.get(itemId)).toBeUndefined() @@ -695,170 +724,3 @@ describe('re-adding a tombstoned row', () => { expect(renderJournalState(state).items).toEqual([]) }) }) - -describe('producer linkage round-trips through the reducer', () => { - const identity: AgentJournalItemIdentity = { - provider: 'claude', - sessionId: 'claude-session', - uuid: 'child-1' - } - const linkage = { - agentId: 'task-1', - parentAgentId: 'task-parent', - providerParentRef: 'toolu_1', - producerKind: 'agent' as const, - attempt: 2 - } - - it('copies the whole bundle onto the render item on the plain item path', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage - }) - ) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'lifecycle-batch', - settlementId: 'settle-1', - mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], - ...base(1), - ...linkage - }) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('reads each mutation of a mixed batch as its own producer', () => { - // A batch can CREATE rows several agents produced — a settlement landing - // before any checkpoint did. The mutation that names a producer is that - // producer's; the one naming none is the session's own, beside it. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - journalLifecycleBatchRowBuilder( - () => state, - 'settle-mixed', - [ - { kind: 'item', identity, body: text('child'), linkage }, - { - kind: 'item', - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, - body: text('own') - } - ], - { fence: 1 } - )(1, 1_001) - ) - - const [child, own] = renderJournalState(state).items - expect(child).toMatchObject({ body: text('child'), ...linkage }) - expect(own?.body).toEqual(text('own')) - expect(own && 'agentId' in own).toBe(false) - }) - - it('lets a correction win over the provisional row, without moving the bubble', () => { - // Write-through then correct: the row is written under the spawn call's own - // id, then re-appended under the canonical one. Revision is assigned inside - // the journal's serialized write step, so the later append always outranks - // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does - // not relocate it in the timeline. - const state = createJournalReducerState('session-1', EPOCH) - const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage: provisional - }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 9, - fence: 1, - ts: 9_999, - linkage - }) - ) - - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) - }) - - it('does not let a stale checkpoint undo a correction that already landed', () => { - // A text checkpoint carrying the OLD stamp, submitted after the correction, - // would re-root the row. It cannot: revision is read at write time, so the - // last write wins and the lane resolves linkage fresh on every checkpoint. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body: text('a'), seq: 1, fence: 1, ts: 1, linkage }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('a and more'), - seq: 2, - fence: 1, - ts: 2, - linkage - }) - ) - const items = renderJournalState(state).items - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it('keeps linkage when a later revision rewrites the row', () => { - // The resolved-append path lost the marker once before by rebuilding the - // row without it, so the SECOND write is the one that matters here. - const state = createJournalReducerState('session-1', EPOCH) - for (const [seq, body] of [ - [1, text('look')], - [2, text('looking at the lane')] - ] as const) { - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body, seq, fence: 1, ts: 1_000 + seq, linkage }) - ) - } - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it("renders a row that predates linkage as the session's own", () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'item', - itemId: 'i-legacy', - revision: 1, - body: text('written before linkage existed'), - ...base(1) - }) - const item = renderJournalState(state).items[0] - expect(item && 'agentId' in item).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index d7e22575702..ed12c4a6cba 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -5,9 +5,10 @@ // Rules: highest revision wins, a tombstone removes, a late lower revision is // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item -// (a later revision updates the body, it does not move the bubble). Producer -// linkage is likewise the creating write's: a revision naming no producer keeps -// it, one naming any replaces it. +// (a later revision updates the body, it does not move the bubble) — except a +// queued message, which sits where its handover put it. Producer linkage is +// likewise the creating write's: a revision naming no producer keeps it, one +// naming any replaces it. import type { AgentJournalAcceptanceReceipt, @@ -21,13 +22,12 @@ import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - agentJournalLinkageFields, - namesAgentJournalProducer -} from '../../../shared/agent-session-journal-producer' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JournalDerivedTurnScope } from './journal-derived-turn-scope' +import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' +import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' @@ -52,6 +52,11 @@ export type JournalReducerState = { * echo from appending a second copy of the user's own message. */ aliases: Map appliedSettlementIds: Set + /** Scope for rows stored without one; rebuilt by replay, never persisted. */ + derivedTurnScope: JournalDerivedTurnScope + /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the + * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ + latestPersonTurnSequence: number } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -68,7 +73,9 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), - appliedSettlementIds: new Set() + appliedSettlementIds: new Set(), + derivedTurnScope: new JournalDerivedTurnScope(), + latestPersonTurnSequence: 0 } } @@ -85,17 +92,17 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, row.itemId, row.body) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) - upsertItem( + upsertJournalItem( state, itemId, row.revision, - journalRenderItem(itemId, row.revision, row.body, row), + journalRenderItem(itemId, row.revision, row.body, row, statedOrDerivedTurnScope(state, row)), row.fence ) return } if (row.kind === 'tombstone') { - removeItem(state, resolveItemId(state, row.itemId), row.revision) + removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) return } if (row.kind === 'lifecycle-batch') { @@ -111,17 +118,18 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo const itemId = resolveJournalItemId(state, mutation.itemId, body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) const producer = journalBatchMutationProducer(row, mutation) - const item = journalRenderItem(itemId, revision, body, row, producer, sequenceIndex) - upsertItem(state, itemId, revision, item, row.fence) + const scope = statedOrDerivedTurnScope(state, mutation) + const item = journalRenderItem(itemId, revision, body, row, scope, producer, sequenceIndex) + upsertJournalItem(state, itemId, revision, item, row.fence) } else { - removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) + removeJournalItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } } rememberAppliedSettlementId(state, row.settlementId) return } if (row.kind === 'submission') { - applySubmission(state, row) + applyJournalSubmission(state, row) return } applyJournalDispatchRow(state, row) @@ -150,16 +158,38 @@ export function resolveJournalItemId( if (aliased) { return aliased } - const identity = parseAgentJournalItemKey(itemId) - if ( - !body || - body.kind !== 'message' || - body.role !== 'user' || - !identity || - identity.provider === 'orca' - ) { + const submissionId = journalEchoClaimant(state, itemId, body) + if (!submissionId) { return itemId } + state.aliases.set(itemId, submissionId) + return submissionId +} + +/** A user message the provider wrote: the only item a submission's echo can be. */ +export function isProviderUserMessageEcho( + itemId: string, + body: AgentJournalRenderItem['body'] +): boolean { + const identity = parseAgentJournalItemKey(itemId) + return ( + body.kind === 'message' && + body.role === 'user' && + identity !== null && + identity.provider !== 'orca' + ) +} + +/** The submission item a provider's echo of a user message would fold into, read without + * claiming it; null when the item is not such an echo, or no submission may claim it. */ +export function journalEchoClaimant( + state: JournalReducerState, + itemId: string, + body?: AgentJournalRenderItem['body'] +): string | null { + if (!body || !isProviderUserMessageEcho(itemId, body)) { + return null + } const fingerprint = structuredAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: state.sessionId, @@ -180,129 +210,13 @@ export function resolveJournalItemId( candidate.payloadFingerprint === fingerprint && state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 ) - if (!submission) { - return itemId - } - const submissionId = agentJournalSubmissionKey(submission.clientMessageId) - state.aliases.set(itemId, submissionId) - return submissionId + return submission ? agentJournalSubmissionKey(submission.clientMessageId) : null } function resolveItemId(state: JournalReducerState, itemId: string): string { return state.aliases.get(itemId) ?? itemId } -function upsertItem( - state: JournalReducerState, - itemId: string, - revision: number, - next: AgentJournalRenderItem, - fence: number -): void { - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - if (!existing) { - state.items.set(itemId, next) - state.itemFences.set(itemId, fence) - state.tombstones.delete(itemId) - return - } - // Creation sequence is the ordering key; a revision refreshes content only. - // `observedAt` is pinned with it: clients sort the timeline by that timestamp, - // so letting a revision advance it makes the row jump past everything that - // landed in between — the provider's own echo of a send revises the submission - // row, which relocated the user's bubble below later rows. - const submitted = - existing.body.kind === 'message' && - existing.body.role === 'user' && - parseAgentJournalItemKey(itemId)?.provider === 'orca' - const { sequenceIndex: _revisedAt, ...revised } = next - state.items.set(itemId, { - ...revised, - // Settlements, prompt answers and reopen sweeps revise rows any agent wrote - // without naming one; each would otherwise hand a subagent's row to the session. - ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), - // Provider history may normalize text or omit local attachments from the original send. - body: submitted ? existing.body : next.body, - sequence: existing.sequence, - ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), - observedAt: existing.observedAt - }) - state.tombstones.delete(itemId) -} - -function removeItem(state: JournalReducerState, itemId: string, revision: number): void { - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - state.tombstones.set(itemId, revision) - state.items.delete(itemId) - state.itemFences.delete(itemId) -} - -function applySubmission( - state: JournalReducerState, - row: Extract -): void { - state.submissions.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - fence: row.fence, - payloadFingerprint: row.payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: row.ts, - resolvedAt: null, - ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) - }) - const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row), row.fence) -} - -function acceptSubmissionFromProviderItem( - state: JournalReducerState, - providerItemId: string, - resolvedItemId: string, - row: Pick -): void { - if (providerItemId === resolvedItemId) { - return - } - const submission = [...state.submissions.values()].find( - (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId - ) - if ( - !submission || - submission.dispatchState === 'accepted' || - submission.dispatchState === 'rejected' - ) { - return - } - submission.fence = row.fence - submission.dispatchState = 'accepted' - submission.providerItemId = providerItemId - submission.reason = null - submission.resolvedAt = row.ts - delete submission.recovered - state.receipts.set(submission.clientMessageId, { - clientMessageId: submission.clientMessageId, - providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - /** Project the folded state into the client-facing snapshot. */ export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // The journal position is the sole ordering key; map insertion order is not, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 516086eed3d..fd58b2c25e4 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -1,7 +1,8 @@ import type { AgentJournalItemBody, AgentJournalProducerLinkage, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields, @@ -19,6 +20,7 @@ export function journalRenderItem( revision: number, body: AgentJournalItemBody, row: JournalRow, + turnScope: AgentJournalTurnScope, producer: AgentJournalProducerLinkage = row, /** Which of the row's writes this is; only a lifecycle batch has more than one. */ sequenceIndex = 0 @@ -32,6 +34,7 @@ export function journalRenderItem( observedAt: row.ts, ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), + turnScope, ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts index f9ef09e688d..78533945671 100644 --- a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts +++ b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts @@ -14,6 +14,7 @@ // sequence owns the epoch and stops the retry. import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' import { deleteJournalRowSuffix } from './journal-row-table' const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?' @@ -47,7 +48,7 @@ export function clearJournalRepairMarker(db: Database.Database, sessionId: strin /** Drop the rejected suffix and record that it is owed, atomically. */ export function deleteJournalRepairedSuffix(input: { - db: Database.Database + database: JournalHostDatabase sessionId: string epoch: string /** First sequence of the rejected suffix. */ @@ -56,14 +57,9 @@ export function deleteJournalRepairedSuffix(input: { contentFrom: number now: number }): number { - input.db.exec('BEGIN IMMEDIATE') - try { - const deleted = deleteJournalRowSuffix(input.db, input.sessionId, input.epoch, input.fromSeq) - input.db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) - input.db.exec('COMMIT') + return input.database.transaction((db) => { + const deleted = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq) + db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) return deleted - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + }) } diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 4428e13953e..2f28adf0d54 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Wiring the restart reconciler: what provider history is allowed to decide // about a submission the crash boundary could only doubt. @@ -14,7 +15,7 @@ import type { import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' @@ -48,7 +49,7 @@ const journals = createTrackedJournalOpener() async function open() { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) @@ -215,7 +216,8 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const journal = await open() // An identical message, delivered and committed BEFORE the one that crashed. await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1 + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await journal.appendSubmission({ clientMessageId: 'cm_1', diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 7b36fc88dc6..f1484bf5da8 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -35,6 +35,8 @@ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { return ( body?.kind === 'message' && body.role === 'user' && + // A conversation command leaves no user item in provider history to find. + body.command === undefined && body.blocks.length === 1 && body.blocks[0]?.type === 'text' && body.blocks[0].text.trim().length > 0 diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index ee56f28f8a2..46d41ecb7cf 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -3,6 +3,8 @@ import type { AgentJournalItemIdentity, AgentJournalMessageItem, AgentJournalProducerLinkage, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' @@ -25,6 +27,7 @@ import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { assertSubmissionIdUnused } from './journal-write-guards' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -33,7 +36,7 @@ export function journalItemRowBuilder( state: () => JournalReducerState, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: AgentJournalProducerLinkage & { fence: number; observedAt?: number; recovered?: true } + options: AgentJournalRowAttribution & { fence: number; observedAt?: number; recovered?: true } ): RowBuilder { return (seq, ts) => buildJournalItemRow({ @@ -44,7 +47,8 @@ export function journalItemRowBuilder( fence: options.fence, ts: options.observedAt ?? ts, recovered: options.recovered, - linkage: options + linkage: options, + turnScope: options.turnScope }) } @@ -65,10 +69,28 @@ export function journalSubmissionRowBuilder( body: AgentJournalMessageItem fence: number handoverRecorded?: true - } + queuedMessageId?: string + origin?: 'client' | 'host' + }, + /** Present when the append hands off a queued draft: the row names that draft, stamped here + * from the consume itself so no hand-off path can leave the link off. */ + consume?: { messageId: string } ): RowBuilder { - return (seq, ts) => - buildJournalSubmissionRow({ state: state(), providerHandle, ...input, seq, ts }) + return (seq, ts) => { + assertSubmissionIdUnused(state().submissions, input.clientMessageId) + if (consume && (input.queuedMessageId ?? consume.messageId) !== consume.messageId) { + throw new Error(`submission ${input.clientMessageId} names a draft it does not consume`) + } + const queuedMessageId = consume?.messageId ?? input.queuedMessageId + return buildJournalSubmissionRow({ + state: state(), + providerHandle, + ...input, + ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), + seq, + ts + }) + } } export function journalDispatchRowBuilder( @@ -76,7 +98,9 @@ export function journalDispatchRowBuilder( input: ResolveDispatchInput ): RowBuilder { const providerItemId = - input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null + input.state === 'accepted' && input.providerIdentity + ? agentJournalItemKey(input.providerIdentity) + : null // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. return (seq, ts) => ({ kind: 'dispatch', @@ -86,7 +110,8 @@ export function journalDispatchRowBuilder( reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), - ...(input.recovered ? { recovered: input.recovered } : {}) + ...(input.recovered ? { recovered: input.recovered } : {}), + ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) }) } @@ -109,6 +134,8 @@ export type JournalLifecycleMutationInput = /** Who wrote the row. Absent ⇒ the session's own agent on a first write, * and the row's existing producer on a revision. */ linkage?: AgentJournalProducerLinkage + /** Which turn the row belongs to. Kept from the write that creates the row. */ + turnScope: AgentJournalTurnScope } | { kind: 'tombstone'; identity: AgentJournalItemIdentity } @@ -117,13 +144,14 @@ export type JournalLifecycleMutationInput = * settled before any checkpoint landed — and one batch can mix producers. * The session's own rows carry no key at all: absence is the claim. */ export function journalLifecycleItemMutation( - producer: AgentJournalProducerLinkage, + attribution: AgentJournalRowAttribution, identity: AgentJournalItemIdentity, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return namesAgentJournalProducer(producer) - ? { kind: 'item', identity, body, linkage: agentJournalLinkageFields(producer) } - : { kind: 'item', identity, body } + const { turnScope } = attribution + return namesAgentJournalProducer(attribution) + ? { kind: 'item', identity, body, turnScope, linkage: agentJournalLinkageFields(attribution) } + : { kind: 'item', identity, body, turnScope } } /** The persisted form of one mutation, shared with the partitioner's size probe @@ -139,6 +167,7 @@ export function journalLifecycleMutationRow( itemId, revision, body: mutation.body, + turnScope: mutation.turnScope, ...agentJournalLinkageFields(mutation.linkage) } : { kind: 'tombstone', itemId, revision } @@ -212,6 +241,7 @@ export function buildJournalItemRow(input: { ts: number recovered?: true linkage?: AgentJournalProducerLinkage + turnScope: AgentJournalTurnScope }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -229,6 +259,7 @@ export function buildJournalItemRow(input: { body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), ...(input.recovered ? { recovered: input.recovered } : {}), + turnScope: input.turnScope, ...agentJournalLinkageFields(input.linkage) } } @@ -267,6 +298,8 @@ export function buildJournalSubmissionRow(input: { fence: number ts: number handoverRecorded?: true + queuedMessageId?: string + origin?: 'client' | 'host' }): JournalSubmissionRow { return { kind: 'submission', @@ -275,6 +308,8 @@ export function buildJournalSubmissionRow(input: { providerHandle: input.providerHandle, body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), - ...(input.handoverRecorded ? { handoverRecorded: true } : {}) + ...(input.handoverRecorded ? { handoverRecorded: true } : {}), + ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), + ...(input.origin !== undefined ? { origin: input.origin } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index fe23a5ddcb5..623c97445a6 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Context facts ride turn rows. Every kind the writer produces must replay, and // one this build cannot read must cost the fact, never the row or the journal. @@ -11,10 +12,13 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' import { parseJournalRow } from './journal-row-schema' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + updateTestJournalRowJson +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -61,7 +65,7 @@ const journals = createTrackedJournalOpener() const open = () => journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => ++clock, mintEpoch: () => 'epoch-1' }) @@ -91,7 +95,10 @@ describe('context facts on replayed turn rows', () => { it('replays every part and kind the writer produces, unchanged', async () => { const journal = await open() for (const [index, facts] of FACTS.entries()) { - await journal.appendItem(row(index), turn(`turn-${index}`, facts), { fence: 1 }) + await journal.appendItem(row(index), turn(`turn-${index}`, facts), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const written = journal.snapshot().items.map((item) => item.body) await journal.close() @@ -104,27 +111,24 @@ describe('context facts on replayed turn rows', () => { it('keeps a turn row whose facts it cannot read, and everything after it, minus the facts', async () => { const journal = await open() - await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { fence: 1 }) + await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendItem( row(1), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { - const stored: unknown = opened.db - .prepare('SELECT row_json FROM journal_rows WHERE seq = 2') - .get() - const rowJson = - typeof stored === 'object' && stored !== null && 'row_json' in stored ? stored.row_json : '' - const future = JSON.parse(String(rowJson)) + const rowJson = liveTestJournalRows(opened.db, IDENTITY.sessionId)[1]?.rowJson ?? '' + const future = JSON.parse(rowJson) future.body.contextUsage = { used: { kind: 'measured-later', tokens: 'many' } } - opened.db - .prepare('UPDATE journal_rows SET row_json = ? WHERE seq = 2') - .run(JSON.stringify(future)) + updateTestJournalRowJson(opened.db, IDENTITY.sessionId, 2, JSON.stringify(future)) } finally { - opened.db.close() + opened.close() } const reopened = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index b764ebd5ec9..616f4b55756 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -1,11 +1,14 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows +} from './journal-host-database-test-support' // Which rows an older host can still read: only rows that carry a turn item // are stamped with the version it does not know, and the epoch row never is. @@ -33,26 +36,24 @@ describe('journal row schema versions', () => { providerHandle: { kind: 'codex', threadId: 'thread-1' } }, now: () => 1_000, - journalDir: join(root, 'session-1') + stateDirectory: join(root, 'session-1') }) const identity = { provider: 'orca' as const, clientMessageId: 'm1' } await journal.appendItem( identity, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: 'session-1', recordId: 'turn-lifecycle:t1' }, agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() - const opened = openJournalDatabase(journalDatabaseFile(join(root, 'session-1'))) + const opened = openTestJournalHostDatabase(join(root, 'session-1')) try { - const stored = opened.db - .prepare('SELECT row_json FROM journal_rows ORDER BY seq') - .all() - .map((row) => JSON.parse(String((row as { row_json: string }).row_json))) + const stored = liveTestJournalRows(opened.db, 'session-1') + .map((row) => JSON.parse(row.rowJson)) .map((row: { kind: string; v: number }) => [row.kind, row.v]) expect(stored).toEqual([ ['epoch', 2], @@ -60,7 +61,7 @@ describe('journal row schema versions', () => { ['item', 3] ]) } finally { - opened.db.close() + opened.close() } }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 870b5259357..1d013255b21 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, parseJournalRow, @@ -277,7 +280,8 @@ describe('producer linkage on the persisted row', () => { seq: 1, fence: 1, ts: 1_700_000_000_000, - ...(withLinkage ? { linkage } : {}) + ...(withLinkage ? { linkage } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) const parsed = parseJournalRow(JSON.stringify(row)) return parsed.ok ? parsed.row : null @@ -348,10 +352,21 @@ describe('producer linkage on the persisted row', () => { it('round-trips a batch mutation that names its own producer, with no version bump', () => { const state = createJournalReducerState('session-1', 'epoch-1') - const own = { kind: 'item' as const, identity: { ...identity, uuid: 'u-own' }, body } + const own = { + kind: 'item' as const, + identity: { ...identity, uuid: 'u-own' }, + body, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } const build = (child: JournalLifecycleMutationInput) => journalLifecycleBatchRowBuilder(() => state, 'settle-1', [child, own], { fence: 1 })(1, 1) - const row = build({ kind: 'item', identity, body, linkage }) + const row = build({ + kind: 'item', + identity, + body, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const parsed = parseJournalRow(JSON.stringify(row)) const mutations = parsed.ok && parsed.row.kind === 'lifecycle-batch' ? parsed.row.mutations : [] @@ -359,7 +374,9 @@ describe('producer linkage on the persisted row', () => { expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host // ignores the unknown keys rather than latching the journal read-only. - expect(row.v).toBe(build({ kind: 'item', identity, body }).v) + expect(row.v).toBe( + build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v + ) }) it('keeps a batch mutation but drops its unusable producer id', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 786d422876c..1c7ea36eaef 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -12,6 +12,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { @@ -37,6 +38,10 @@ type JournalRowBase = AgentJournalProducerLinkage & { ts: number /** Set when crash reconciliation appended the row after the fact. */ recovered?: true + /** Which turn the item this row creates belongs to. Rides the base, and is not a `v` bump, + * for the reason linkage does. Absent on rows from hosts that predate it: the reducer + * derives one for them on read. */ + turnScope?: AgentJournalTurnScope } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -81,8 +86,18 @@ export type JournalSubmissionRow = JournalRowBase & { /** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer * dispatched in the same step. Older readers keep the key and ignore it. */ handoverRecorded?: true + /** The queued draft this submission hands off; absent for a direct send. Older readers keep + * the key and ignore it. */ + queuedMessageId?: string + /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or + * a queued card they sent now); `host` for Orca's own — orchestration mail, a restart + * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it + * was recorded. Older readers keep the key and ignore it. */ + origin?: JournalSubmissionOrigin } +export type JournalSubmissionOrigin = 'client' | 'host' + export type JournalDispatchRow = JournalRowBase & { kind: 'dispatch' clientMessageId: string @@ -90,6 +105,8 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `pending`: the turn the message was handed into, which becomes its row's scope. */ + turnScope?: AgentJournalTurnScope /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact @@ -105,6 +122,7 @@ export type JournalLifecycleMutation = itemId: string revision: number body: AgentJournalItemBody + turnScope?: AgentJournalTurnScope }) | { kind: 'tombstone'; itemId: string; revision: number } @@ -170,10 +188,12 @@ export function parseJournalRow(line: string): JournalRowParse { } const upcast = upcastRow(record, version) dropUnusableProducerLinkage(upcast) + dropUnusableTurnScope(upcast) if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { for (const mutation of upcast.mutations) { if (isPlainObject(mutation)) { dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) } } } @@ -202,6 +222,24 @@ function dropUnusableProducerLinkage(record: Record): void { } } +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + /** Context facts this build cannot read, removed from the turn row that carries * them. Same reasoning as linkage: they are an annotation on the turn, and * rejecting the row for them would truncate the journal from that row on. */ diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index f07ad19d4d5..4665a057115 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -1,40 +1,41 @@ // Every statement the journal issues against `journal_rows` / `journal_sessions`. // -// Each one is a prefix or range scan on the `(session_id, epoch, seq)` primary -// key; there is no secondary index, and no `max(seq)` tip query — replay folds -// the epoch to obtain `lastSequence`, so nothing needs the tip from SQL. -// Columns are always named: `SELECT *` is uncacheable and can drop a column. +// Rows are keyed by the chat's identity: `(session_id, epoch, seq)`, the table's primary key. A +// replay is one range scan of that key, and retiring an epoch deletes one range of it. Columns are +// always named: `SELECT *` is uncacheable and can drop a column. import type Database from '../../sqlite/sync-database' import { serializeJournalRow, type JournalRow } from './journal-row-schema' export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string } -const SELECT_SESSION = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' -const UPSERT_SESSION = `INSERT INTO journal_sessions (session_id, epoch, updated_at) +const SELECT_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' +const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, updated_at = excluded.updated_at` +ON CONFLICT(session_id) DO UPDATE SET + workspace_id = excluded.workspace_id, epoch = excluded.epoch` const INSERT_ROW = 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' -const SELECT_EPOCH_ROWS = `SELECT epoch, seq, ts, row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? ORDER BY seq ASC` -const SELECT_ROWS_AFTER = `SELECT epoch, seq, ts, row_json FROM journal_rows +const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?' +const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' +const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? +AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null { - const row = db.prepare(SELECT_SESSION).get(sessionId) as { epoch?: string } | undefined - return row?.epoch ?? null + const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch + return typeof epoch === 'string' ? epoch : null } -export function upsertJournalSessionRow( +/** Points the chat at `epoch`. Only an epoch change writes this row. */ +export function publishJournalSessionEpoch( db: Database.Database, - sessionId: string, - epoch: string, - updatedAt: number + identity: { sessionId: string; workspaceId: string }, + epoch: string ): void { - db.prepare(UPSERT_SESSION).run(sessionId, epoch, updatedAt) + db.prepare(PUBLISH_SESSION_EPOCH).run(identity.sessionId, identity.workspaceId, epoch) } export function insertJournalRow( @@ -47,20 +48,12 @@ export function insertJournalRow( return Buffer.byteLength(rowJson, 'utf8') } -export function readJournalEpochRows( - db: Database.Database, - sessionId: string, - epoch: string -): JournalStoredRow[] { - return toStoredRows(db.prepare(SELECT_EPOCH_ROWS).all(sessionId, epoch)) -} - // Why pages, not `.iterate()`: a lazily consumed cursor pins a read snapshot for as long as the // consumer reduces, and a WAL checkpoint cannot pass an open snapshot. Each page is one completed // statement, so the consumer's memory is bounded by a page while no snapshot outlives a fetch. const EPOCH_ROW_PAGE_SIZE = 128 -/** Epoch rows in sequence order, fetched one completed statement at a time. */ +/** The epoch's rows in sequence order, fetched one completed statement at a time. */ export function* iterateJournalEpochRows( db: Database.Database, sessionId: string, @@ -85,22 +78,29 @@ export function readJournalRowsAfter( afterSeq: number, limit?: number ): JournalStoredRow[] { - if (limit !== undefined) { - return toStoredRows( - db.prepare(SELECT_ROWS_AFTER_LIMITED).all(sessionId, epoch, afterSeq, limit) - ) - } - return toStoredRows(db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq)) + const rows = + limit !== undefined + ? db.prepare(SELECT_ROWS_AFTER_LIMITED).all(sessionId, epoch, afterSeq, limit) + : db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq) + return rows.flatMap((row) => + typeof row.seq === 'number' && typeof row.ts === 'number' && typeof row.row_json === 'string' + ? [{ epoch, seq: row.seq, ts: row.ts, rowJson: row.row_json }] + : [] + ) } -/** - * Unqualified on purpose. One database per session means every row here belongs - * to this session, and the unqualified form takes SQLite's truncate - * optimization: measured at 0.26% of the database in WAL bytes where the - * `WHERE session_id = ?` form rewrote every emptied leaf at up to 99%. - */ -export function deleteAllJournalRows(db: Database.Database): void { - db.exec('DELETE FROM journal_rows') +/** Every row of a retired epoch, in the transaction that retires it. */ +export function deleteJournalEpochRows( + db: Database.Database, + sessionId: string, + epoch: string +): void { + db.prepare(DELETE_EPOCH).run(sessionId, epoch) +} + +/** Rows of this chat under any epoch its pointer does not name: a copy that never published. */ +export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { + db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) } /** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */ @@ -113,10 +113,3 @@ export function deleteJournalRowSuffix( const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq) return Number(deleted.changes ?? 0) } - -function toStoredRows(rows: readonly unknown[]): JournalStoredRow[] { - return rows.map((entry) => { - const record = entry as { epoch: string; seq: number; ts: number; row_json: string } - return { epoch: record.epoch, seq: record.seq, ts: record.ts, rowJson: record.row_json } - }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index dc08739a8bc..aa0078b88a5 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -9,15 +9,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' -import { - insertJournalRow, - readJournalEpochRows, - upsertJournalSessionRow -} from './journal-row-table' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalRow } from './journal-row-schema' import { JournalRowWriter } from './journal-row-writer' +import { + openTestJournalHostDatabase, + readTestJournalRows, + insertTestJournalRow, + publishTestJournalEpoch +} from './journal-host-database-test-support' const SESSION_ID = 'session-1' const EPOCH = 'epoch-1' @@ -38,19 +38,19 @@ function row(seq: number, ts: number): JournalRow { describe('journal row writer', () => { let root: string - let database: OpenJournalDatabase + let database: JournalHostDatabase let readOnly = false beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-')) - database = openJournalDatabase(journalDatabaseFile(root)) - upsertJournalSessionRow(database.db, SESSION_ID, EPOCH, 1) + database = openTestJournalHostDatabase(root) + publishTestJournalEpoch(database.db, SESSION_ID, EPOCH) readOnly = false }) afterEach(async () => { try { - database.db.close() + database.close() } catch { // Already closed by the case. } @@ -79,13 +79,13 @@ describe('journal row writer', () => { it('rolls the transaction back and sets no latch when the insert fails', async () => { const { writer, committedRows } = writerHarness() // A row already occupies sequence 1, so the insert violates the primary key. - insertJournalRow(database.db, SESSION_ID, row(1, 1)) + insertTestJournalRow(database.db, SESSION_ID, row(1, 1)) await expect(writer.enqueue(row)).rejects.toThrow() expect(readOnly).toBe(false) expect(committedRows).toHaveLength(0) - expect(readJournalEpochRows(database.db, SESSION_ID, EPOCH)).toHaveLength(1) + expect(readTestJournalRows(database.db, SESSION_ID, EPOCH)).toHaveLength(1) // Still writable: there is no ambiguity for a latch to protect against. await expect(writer.enqueue((seq, ts) => row(seq + 1, ts))).resolves.toMatchObject({ kind: 'item' diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 85ff7da7a3f..a29585580eb 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -1,35 +1,54 @@ import type Database from '../../sqlite/sync-database' -import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' +import { insertJournalRow } from './journal-row-table' +import type { JournalHostDatabase } from './journal-host-database' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' +/** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row + * insert; a throw rolls the whole append back. Synchronous by construction so + * nothing can interleave inside the transaction. */ +export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void + export type JournalRowWriterDeps = { sessionId: string now: () => number serialize: (run: () => Promise) => Promise - database: () => { db: Database.Database } + database: () => JournalHostDatabase readOnly: () => boolean highestFence: () => number nextSequence: () => number commit: (row: JournalRow) => void + /** Standing hook run for EVERY appended row — the queued-draft returned + * transition rides here so no rejection path can bypass it. Bookkeeping: it + * runs in its own savepoint, so its failure is reported and never vetoes the row. */ + inTransaction?: JournalRowTransactionHook + /** After any rollback, so a cache filled inside the transaction cannot outlive it. */ + rolledBack?: () => void } +const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping' + export class JournalRowWriter { constructor(private readonly deps: JournalRowWriterDeps) {} - enqueue(build: (seq: number, ts: number) => JournalRow): Promise { + enqueue( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): Promise { return this.deps.serialize(async () => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const row = build(this.deps.nextSequence(), this.deps.now()) assertJournalFence(row.fence, this.deps.highestFence()) - const { db } = this.deps.database() - db.exec('BEGIN IMMEDIATE') try { - insertJournalRow(db, this.deps.sessionId, row) - upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts) - db.exec('COMMIT') + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + this.runBookkeeping(db, row) + }) } catch (error) { - db.exec('ROLLBACK') + this.deps.rolledBack?.() throw error } // COMMIT landed, so the row is durable: adopt it before anything that can @@ -39,4 +58,36 @@ export class JournalRowWriter { return row }) } + + /** Assign the next sequence, make the row durable, and fold it through the SAME reducer + * replay uses — all inside one serialized step — answering where the row landed. */ + append( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): Promise { + return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + } + + private runBookkeeping(db: Database.Database, row: JournalRow): void { + const hook = this.deps.inTransaction + if (!hook) { + return + } + db.exec(`SAVEPOINT ${BOOKKEEPING_SAVEPOINT}`) + try { + hook(db, row) + db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) + } catch (error) { + db.exec(`ROLLBACK TO ${BOOKKEEPING_SAVEPOINT}`) + db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) + this.deps.rolledBack?.() + // The draft store re-derives what this missed from the committed rows: at open, and in + // the drain step before a draft sends. + console.warn('[journal-append] row bookkeeping skipped:', { + sessionId: this.deps.sessionId, + kind: row.kind, + error: error instanceof Error ? error.message : String(error) + }) + } + } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts deleted file mode 100644 index af53cd16d3d..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -// `close()`: enqueue-time admission, and the fulfilled/rejected split. -// -// The two failures this file exists to prevent: an append enqueued in the same -// turn as `close()` being rejected AFTER the queue accepted it, and a rejected -// close leaving a connection live but permanently unreachable through the API. - -import { access, mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { journalDatabaseFile } from './journal-paths' -import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } -} - -let root: string -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function body(value: string): AgentJournalItemBody { - return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } -} - -function openJournal(): Promise { - return journals.open({ identity: IDENTITY, journalDir: root }) -} - -/** Replaces the store's own release step, which is the only step that can - * reject the attempt in production. */ -function injectReleaseFailure(journal: AgentSessionJournal): { - calls: () => number - stopFailing: () => void -} { - const internals = journal as unknown as { database: { db: { close: () => void } } } - const release = internals.database.db.close.bind(internals.database.db) - let calls = 0 - let failing = true - internals.database.db.close = () => { - calls += 1 - if (failing) { - throw new Error('injected release failure') - } - release() - } - return { calls: () => calls, stopFailing: () => (failing = false) } -} - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-close-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('closed-state admission happens at enqueue', () => { - it('completes a write enqueued in the same turn as the close', async () => { - const journal = await openJournal() - const append = journal.appendItem(item(1), body('before'), { fence: 1 }) - const closed = journal.close() - - await expect(append).resolves.toBeDefined() - await expect(closed).resolves.toBeUndefined() - const reopened = await openJournal() - expect(reopened.snapshot().items).toHaveLength(1) - }) - - it('refuses a write offered while the close is still in flight, without queueing it', async () => { - const journal = await openJournal() - const closing = journal.close() - const refused = journal.appendItem(item(1), body('during'), { fence: 1 }) - - // The rejection is available before the close step has run: it never joined - // the queue, so nothing is ever chained behind a close. - await expect(refused).rejects.toMatchObject({ code: 'journal_closed' }) - await expect(closing).resolves.toBeUndefined() - }) - - it('refuses every write entry point after the close has settled', async () => { - const journal = await openJournal() - await journal.close() - const settle = (attempt: Promise): Promise => - attempt.then( - () => new Error('resolved instead of refusing'), - (error: unknown) => error - ) - const refusals = [ - settle(journal.appendItem(item(1), body('after'), { fence: 1 })), - settle(journal.appendTombstone(item(3), { fence: 1 })), - settle( - journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: 'f', - body: { kind: 'message', role: 'user', blocks: [] }, - fence: 1 - }) - ), - settle( - journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { - surface: 'rejection' - }), - fence: 1 - }) - ), - settle( - journal.appendLifecycleBatch({ - settlementId: 'settle', - fence: 1, - mutations: [{ kind: 'item', identity: item(4), body: body('x') }] - }) - ), - settle(journal.rollEpoch('handle_forked', 1)), - settle(journal.replaceEpochItems('handle_forked', 1, [])) - ] - for (const refusal of refusals) { - expect(await refusal).toMatchObject({ code: 'journal_closed' }) - } - }) - - // The property part 1 rests on: every entry point reaches the gate in the - // caller's own turn, so a refusal never advances the queue. - it('rejects without waiting for the queue to advance', async () => { - const journal = await openJournal() - const inFlight = journal.appendItem(item(1), body('admitted'), { fence: 1 }) - const closing = journal.close() - - // Settles while the admitted append is still running: it reached the gate in - // the caller's own turn and never joined the queue behind it. - await expect(journal.appendItem(item(2), body('later'), { fence: 1 })).rejects.toMatchObject({ - code: 'journal_closed' - }) - await expect(inFlight).resolves.toBeDefined() - await expect(closing).resolves.toBeUndefined() - }) - - it('resolves a second close as a no-op without running the routine again', async () => { - const journal = await openJournal() - const injected = injectReleaseFailure(journal) - injected.stopFailing() - await journal.close() - expect(injected.calls()).toBe(1) - - await expect(journal.close()).resolves.toBeUndefined() - expect(injected.calls()).toBe(1) - }) -}) - -describe('a rejected close is a real retry', () => { - it('retries the release, releases the handle, and then goes terminal', async () => { - const journal = await openJournal() - await journal.appendItem(item(1), body('durable'), { fence: 1 }) - const injected = injectReleaseFailure(journal) - - await expect(journal.close()).rejects.toThrow('injected release failure') - expect(injected.calls()).toBe(1) - - // Write-closed anyway: retry exists to release the OS handle, never to - // resurrect the store. - await expect(journal.appendItem(item(2), body('after'), { fence: 1 })).rejects.toMatchObject({ - code: 'journal_closed' - }) - - injected.stopFailing() - await expect(journal.close()).resolves.toBeUndefined() - // The retry RE-ENTERED the release. A completion flag on it would skip the - // one step that had not succeeded, and the handle would never be released. - expect(injected.calls()).toBe(2) - const dbPath = journalDatabaseFile(root) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - - // Fulfilment is terminal: a third call must not issue a second db.close(), - // which `node:sqlite` answers with ERR_INVALID_STATE. - await expect(journal.close()).resolves.toBeUndefined() - expect(injected.calls()).toBe(2) - }) - - it('hands concurrent callers the same outcome', async () => { - const journal = await openJournal() - const injected = injectReleaseFailure(journal) - const first = journal.close() - const second = journal.close() - await expect(first).rejects.toThrow('injected release failure') - await expect(second).rejects.toThrow('injected release failure') - expect(injected.calls()).toBe(1) - }) -}) - -// The two rejected readings of the contract, as models, because a green run on -// the real store proves nothing about what the alternatives would have done. -describe('negative controls', () => { - class UnconditionalNoOpClose { - calls = 0 - private called = false - constructor(private readonly release: () => void) {} - async close(): Promise { - if (this.called) { - return - } - this.called = true - this.calls += 1 - this.release() - } - } - - class AlwaysReentrantClose { - calls = 0 - async close(release: () => void): Promise { - this.calls += 1 - release() - } - } - - it('an unconditional second-call no-op leaves a failed close unreleasable', async () => { - let failing = true - let released = false - const model = new UnconditionalNoOpClose(() => { - if (failing) { - throw new Error('injected release failure') - } - released = true - }) - await expect(model.close()).rejects.toThrow('injected release failure') - failing = false - await expect(model.close()).resolves.toBeUndefined() - // Fulfilled without the routine running: the handle is still held. - expect(model.calls).toBe(1) - expect(released).toBe(false) - }) - - it('an always-reentrant close issues the second db.close() that throws', async () => { - let open = true - const release = (): void => { - if (!open) { - throw new Error('ERR_INVALID_STATE: database is not open') - } - open = false - } - const model = new AlwaysReentrantClose() - await model.close(release) - await expect(model.close(release)).rejects.toThrow('database is not open') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.ts b/src/main/native-chat/agent-session-journal/journal-store-close.ts deleted file mode 100644 index f1b56d6a0db..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-close.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Releasing the one SQLite connection a store holds for its lifetime. -// -// The contract, in five parts: -// -// 1. Closed-state admission happens at ENQUEUE, once, and is permanent. The -// flag lives on the store's write gate; it is never cleared, not by a -// rejection and not by a retry. A store that failed to close is still a -// store nobody may write to. -// 2. The close step shares the write queue and BYPASSES that gate — a close -// that consulted the flag it had just set would refuse itself. Same queue -// orders the release behind admitted work; separate gate lets it run. -// 3. One in-flight attempt, shared: concurrent callers get the same outcome. -// 4. Fulfilled is TERMINAL; rejected is not. A later call after fulfilment is a -// genuine no-op — `DatabaseSync.close()` throws `ERR_INVALID_STATE` on a -// second call, so re-entering after success is the bug, not the fix. -// 5. The release is deliberately unguarded. There is no way to ask whether a -// `db.close()` that threw released the handle first, and guarding the step -// would skip it on retry — guaranteeing a permanent leak in exactly the case -// where it did not release. - -import { AgentSessionJournalError } from './journal-write-guards' -import type Database from '../../sqlite/sync-database' - -/** - * The write queue and its closed gate. Admission is checked at ENQUEUE and is - * permanent; the close step reaches the same queue through `serializePastGate`, - * because a close that consulted the flag it had just set would refuse itself. - */ -export class JournalWriteQueue { - private writes: Promise = Promise.resolve() - private closed = false - - constructor(private readonly sessionId: string) {} - - markClosed(): void { - this.closed = true - } - - serialize(run: () => Promise): Promise { - if (this.closed) { - return Promise.reject( - new AgentSessionJournalError( - 'journal_closed', - `agent-session journal for ${this.sessionId} is closed` - ) - ) - } - return this.serializePastGate(run) - } - - serializePastGate(run: () => Promise): Promise { - const started = this.writes.then(run) - this.writes = started.catch(() => undefined) - return started - } -} - -export class JournalConnectionCloser { - private released = false - private inFlight: Promise | null = null - - constructor( - private readonly deps: { - connection: () => Database.Database | null - /** Chains onto the store's write queue past the closed gate. */ - enqueue: (run: () => Promise) => Promise - } - ) {} - - get isReleased(): boolean { - return this.released - } - - close(): Promise { - if (this.released) { - return Promise.resolve() - } - if (this.inFlight) { - return this.inFlight - } - const attempt = this.deps - .enqueue(() => this.release()) - .then( - () => { - this.released = true - this.inFlight = null - }, - (error: unknown) => { - this.inFlight = null - throw error - } - ) - this.inFlight = attempt - return attempt - } - - private async release(): Promise { - const db = this.deps.connection() - if (!db) { - return - } - // SQLite checkpoints and removes the WAL itself when the last connection to - // the database closes. - db.close() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index a4ff455b31f..16d3795cc6a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -7,11 +7,12 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { OpenJournalDatabase } from './journal-database' +import type { JournalHostDatabase } from './journal-host-database' import { JournalEpochController } from './journal-epoch-controller' import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' +import { JournalQueuedMessages } from './journal-queued-messages' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -19,20 +20,29 @@ import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' export type JournalStoreHost = { + /** Fires the journal's commit listener for a durable change that appended no + * row — a standalone draft-table transaction — so readers learn of it the + * same way they learn of a row. */ + notifyCommitted: () => void identity: AgentSessionJournalIdentity - journalDir: string + /** Where the chat's per-chat history lived, for the importer and the format-remnant notice. */ + legacyDirectory: string now: () => number mintEpoch: () => string serialize: (run: () => Promise) => Promise - database: () => OpenJournalDatabase + /** Leave a chat still in its per-chat file uncopied until its first use. */ + deferPerSessionImport: boolean + /** Work the chat's next write waits for. */ + owe: (work: () => Promise) => void + database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean setReadOnly: (readOnly: boolean) => void cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void commit: (row: JournalRow) => void - /** A caller-supplied load, which suppresses replay entirely when present. */ - loaded: () => JournalLoad | null | undefined + /** Records whether the open's replay found an unusable prefix. */ + setOpenedCorrupt: (corrupt: boolean) => void malformedRows: () => number setMalformedRows: (count: number) => void journal: () => AgentSessionJournal @@ -44,6 +54,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + queuedMessages: JournalQueuedMessages /** Restores the store's state from disk. Owned here because it needs the same * collaborators the constructor just built. */ restore: () => Promise @@ -62,9 +73,24 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal cursor: host.cursor, adopt: host.adopt }) + const queuedMessages = new JournalQueuedMessages({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + state: host.state, + committed: host.notifyCommitted + }) return { epochController, - restore: () => restoreJournalStore(host, { epochController }), + queuedMessages, + // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows + // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. + restore: () => + restoreJournalStore(host, { epochController }).then(() => + queuedMessages.repairAndPruneAtOpen() + ), rowWriter: new JournalRowWriter({ sessionId: host.identity.sessionId, now: host.now, @@ -73,7 +99,11 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, highestFence: () => host.state().highestFence, nextSequence: () => host.state().lastSequence + 1, - commit: host.commit + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() }), itemAppender: new JournalItemAppender({ state: host.state, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2210efb253..29f0db6bed6 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -6,19 +6,21 @@ import type { AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalResetReason, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { JournalLoad } from './journal-open' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' export type AgentSessionJournalOptions = { identity: AgentSessionJournalIdentity - journalDir: string + database: JournalHostDatabase now?: () => number mintEpoch?: () => string - /** A caller that already loaded the journal can avoid reading the same files again. */ - loaded?: JournalLoad | null + /** A restore's open: see `AgentSessionJournal.whenImported`. */ + deferPerSessionImport?: boolean } export type JournalReadSince = @@ -29,14 +31,18 @@ export type ResolveDispatchInput = { clientMessageId: string fence: number recovered?: true -} & ( - | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } - | { state: 'pending' } - /** `reason` is what released clients print, `rejection` what newer ones read: both from - * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - | { state: 'unknown'; reason?: string | null } -) +} & + /** A null identity: the provider took the message without echoing an item of its own, as a + * conversation command it carries out in place. */ + ( + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity | null } + /** The turn the message is handed into — the live root turn, or `thread` when none runs. */ + | { state: 'pending'; turnScope: AgentJournalTurnScope } + /** `reason` is what released clients print, `rejection` what newer ones read: both from + * `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason?: string | null } + ) export type JournalAppendResult = { cursor: AgentJournalCursor @@ -44,13 +50,21 @@ export type JournalAppendResult = { revision: number } -export type JournalItemAppendOptions = AgentJournalProducerLinkage & { +export type JournalItemAppendOptions = AgentJournalRowAttribution & { fence: number observedAt?: number recovered?: true } export type JournalTombstoneInput = { fence: number } +/** One reduced item, the producer that wrote it and the turn it was created beside. */ +export type JournalItemLinkageVisitor = ( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } +) => void + export type JournalLifecycleBatchInput = { settlementId: string mutations: readonly JournalLifecycleMutationInput[] @@ -65,6 +79,21 @@ export type JournalSubmissionInput = { fence: number /** The send is accepted now and handed over later, by a `dispatch{pending}` row. */ handoverRecorded?: true + /** Stamped by `appendSubmission` from its consume; a caller-passed value must match it. */ + queuedMessageId?: string + /** Who asked for this turn (`JournalSubmissionRow.origin`). */ + origin?: 'client' | 'host' +} + +/** A submission append that converts a queued draft, in one transaction. */ +export type JournalSubmissionConsume = { + messageId: string + expect: 'waiting' | 'returned' + /** The operation ledger's caller-scoped key; null for the host's own drain. */ + settledByOp: string | null + /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to + * waiting belongs to the process that sent it, not the one that first wrote the card. */ + hostInstance?: string } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index 7b5b6d0dff8..a7163860be9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -1,4 +1,3 @@ -import { mkdir } from 'node:fs/promises' import type { AgentJournalItemBody, AgentJournalItemIdentity @@ -16,10 +15,6 @@ import { staleSubagentRosterRevisions } from './journal-subagent-liveness' * pre-SQLite notice's. Same shape, and neither is only a repair. */ type JournalDisclosure = JournalRepairDisclosure -export async function ensureJournalDir(journalDir: string): Promise { - await mkdir(journalDir, { recursive: true }) -} - export function journalStoreLoadedFields(loaded: JournalLoad) { return { state: loaded.state, @@ -29,8 +24,7 @@ export function journalStoreLoadedFields(loaded: JournalLoad) { } export async function openJournalStoreState(input: { - journalDir: string - loaded: JournalLoad | null | undefined + legacyDirectory: string replay: () => JournalLoad | null /** Drops the rejected suffix and records the rebuild it owes, in ONE * transaction. Corruption is not preserved; replay keeps reporting `corrupt` @@ -52,7 +46,7 @@ export async function openJournalStoreState(input: { setMalformedRows: (count: number) => void readOnly: () => boolean }): Promise { - const loaded = input.loaded !== undefined ? input.loaded : input.replay() + const loaded = input.replay() if (!loaded) { input.start() await discloseFileFormatRemnant(input) @@ -96,7 +90,7 @@ export async function openJournalStoreState(input: { * Upserts by a constant identity, so the offer above is exactly-once in effect: * once the row exists the epoch is no longer empty. */ async function discloseFileFormatRemnant(input: { - journalDir: string + legacyDirectory: string agent: AgentType appendItem: ( identity: JournalDisclosure['identity'], @@ -109,7 +103,7 @@ async function discloseFileFormatRemnant(input: { if (input.readOnly()) { return } - const transcriptPath = findJournalFileFormatRemnant(input.journalDir) + const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) if (!transcriptPath) { return } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index fc69dd339d8..8fce8457b63 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -6,26 +6,53 @@ // same host the collaborators use, so the store keeps the state and this owns // the sequence. +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' -import { replayJournal } from './journal-open' +import { replayJournal, type JournalLoad } from './journal-open' +import { createJournalReducerState } from './journal-reducer' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' import { deleteJournalRepairedSuffix } from './journal-repair-marker' +import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' +import { AgentSessionJournalError } from './journal-write-guards' -export function restoreJournalStore( +export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { + const source = { + database: host.database(), + identity: host.identity, + legacyDirectory: host.legacyDirectory + } + if (source.database.readOnly) { + return restoreFromNewerDatabase(host, source) + } + // A restore reads a chat still in its per-chat file from there, and copies it before its first use. + const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null + if (preview) { + host.owe(async () => { + await importPerSessionJournal(source) + const imported = replayJournal(source.database.db, host.identity.sessionId) + if (!imported) { + throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) + } + host.adopt(imported) + }) + } else { + // A per-chat file left by an earlier build is this chat's newest history: copied in first. + await importPerSessionJournal(source) + } return openJournalStoreState({ - journalDir: host.journalDir, - loaded: host.loaded(), + legacyDirectory: host.legacyDirectory, replay: () => { - const opened = host.database() - return replayJournal(opened.db, opened.readOnly, host.identity.sessionId) + const loaded = preview ?? replayJournal(host.database().db, host.identity.sessionId) + host.setOpenedCorrupt(loaded?.corrupt ?? false) + return loaded }, deleteSuffix: (fromSeq, contentFrom) => deleteJournalRepairedSuffix({ - db: host.database().db, + database: host.database(), sessionId: host.identity.sessionId, epoch: host.state().epoch, fromSeq, @@ -39,7 +66,9 @@ export function restoreJournalStore( publishRepairEpoch: () => collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), + // Repair and file-format notices are about the conversation, not any turn in it. + appendItem: (identity, body, fence) => + host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, @@ -47,3 +76,34 @@ export function restoreJournalStore( readOnly: host.readOnly }) } + +/** + * A newer Orca's database: each chat shows what this build can read of it, from the database or a + * per-chat file never copied in, and nothing is written, copied, repaired or founded. Every write + * the store is asked for is refused as read-only. + */ +async function restoreFromNewerDatabase( + host: JournalStoreHost, + source: Parameters[0] +): Promise { + const { sessionId } = host.identity + let loaded: JournalLoad | null + try { + loaded = + (await previewPerSessionJournal(source)) ?? replayJournal(source.database.db, sessionId) + } catch (error) { + // Tables the newer schema changed: still a chat only an update opens, not a damaged one. + throw new AgentSessionJournalError( + 'journal_read_only', + `agent-session journal for ${sessionId} could not be read under a newer schema`, + { cause: error } + ) + } + host.setOpenedCorrupt(false) + host.adopt({ + state: loaded?.state ?? createJournalReducerState(sessionId, ''), + readOnly: true, + corrupt: false, + malformedRows: 0 + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index df34ef73e73..da6ffc84feb 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Two independent version axes, both fail closed. // // `PRAGMA user_version` the DB SHAPE, known before the first read @@ -5,7 +6,8 @@ // // A newer build can change either alone, so both are needed. -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -15,11 +17,15 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabaseFile } from './journal-paths' +import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + insertTestJournalRowJson +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -49,27 +55,47 @@ function body(value: string): AgentJournalItemBody { function open(): Promise { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) } async function withDatabase(run: (db: Database.Database) => void): Promise { - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { run(opened.db) } finally { - opened.db.close() + opened.close() } } +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + /** Appends a raw `row_json` the way a newer build or a bad write would leave it. */ async function appendRawRow(epoch: string, seq: number, rowJson: string): Promise { await withDatabase((db) => { - db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ).run(IDENTITY.sessionId, epoch, seq, 1, rowJson) + expect(liveTestJournalRows(db, IDENTITY.sessionId)[0]?.epoch).toBe(epoch) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, rowJson) + }) +} + +/** A row only a newer build could have written. */ +function futureRow(epoch: string, seq: number): string { + return JSON.stringify({ + v: 99, + kind: 'item', + epoch, + seq, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer build' } }) } @@ -84,30 +110,63 @@ afterEach(async () => { }) describe('axis 1: the database shape', () => { - it('latches read-only on a newer user_version and writes nothing', async () => { + // A newer build's database opens read-only: its chats read, and nothing here writes to it. + it('reads a database a newer build stamped, refuses every write, and writes nothing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) - const before = await stat(journalDatabaseFile(root)) + const before = await digest(journalDatabasePath(root)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a')]) + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) - // The file this build must not touch is byte-identical afterwards. - await reopened.close() - expect((await stat(journalDatabaseFile(root))).size).toBe(before.size) - await withDatabase((db) => { - expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION + 1) + // A chat the database never held opens empty rather than being founded, and refuses the same way. + const unwritten = await journals.open({ + identity: { ...IDENTITY, sessionId: 'session-2' }, + stateDirectory: root }) + expect(unwritten.isReadOnly).toBe(true) + expect(unwritten.snapshot().items).toEqual([]) + await expect( + unwritten.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ + code: 'journal_read_only' + }) + await journals.closeAll() + expect(await digest(journalDatabasePath(root))).toBe(before) }) - it('refuses the schema escape hatch on a latched store', async () => { + // Tables a newer schema changed read as a chat only an update opens, not as damage. + it('refuses as read-only a chat whose tables a newer build changed', async () => { const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() - await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) + await withDatabase((db) => { + db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_v4') + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + }) + + await expect(open()).rejects.toMatchObject({ code: 'journal_read_only' }) + }) + + it('refuses the schema escape hatch on a store latched by a newer row', async () => { + const journal = await open() + const epoch = journal.epoch + const nextSeq = journal.cursor().sequence + 1 + await journal.close() + await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) const reopened = await open() // With byte-copy quarantine gone there is nothing for `schema_unreadable` to @@ -118,9 +177,13 @@ describe('axis 1: the database shape', () => { expect(reopened.isReadOnly).toBe(true) }) - it('migrates an older user_version forward on reopen', async () => { + // The only older version brought forward; 1 and 2 are refused (journal-database.test.ts). + it('stamps the current user_version on a version-0 file that already has its tables', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma('user_version = 0')) @@ -137,44 +200,36 @@ describe('axis 1: the database shape', () => { describe('axis 2: the row body shape', () => { it('degrades to read-only on a row from a newer build, without skipping it', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() - await appendRawRow( - epoch, - nextSeq, - JSON.stringify({ - v: 99, - kind: 'item', - epoch, - seq: nextSeq, - fence: 1, - ts: 1, - itemId: 'future', - revision: 1, - body: { kind: 'status', text: 'from a newer build' } - }) - ) + await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) await reopened.close() // Never skipped, never deleted: the row this build cannot read is still there. await withDatabase((db) => { - const stored = db.prepare('SELECT row_json FROM journal_rows WHERE seq = ?').get(nextSeq) as { - row_json: string - } - expect(stored.row_json).toContain('"v":99') + const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === nextSeq) + expect(stored?.rowJson).toContain('"v":99') }) }) it('skips a malformed row without giving up the journal, and discloses the skip', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -195,7 +250,10 @@ describe('axis 2: the row body shape', () => { it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -214,7 +272,10 @@ describe('axis 2: the row body shape', () => { it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store-test-open.ts b/src/main/native-chat/agent-session-journal/journal-store-test-open.ts deleted file mode 100644 index fc8bce5af39..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-test-open.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Shared tracked-open helper for journal tests. -// -// Tracking every opened INSTANCE rather than a variable is the point: `close()` -// is idempotent, a module-level `journal` binding can be reassigned to a second -// store mid-suite, and `allSettled` means one failing close cannot skip the rest -// or the directory removal behind it. - -import type { AgentSessionJournal } from './journal-store' -import type { AgentSessionJournalOptions } from './journal-store-contracts' -import { openAgentSessionJournal } from './journal-store-factory' - -export type TrackedJournalOpener = { - open: (options: AgentSessionJournalOptions) => Promise - track: (journal: T) => T - closeAll: () => Promise -} - -export function createTrackedJournalOpener(): TrackedJournalOpener { - const opened: AgentSessionJournal[] = [] - return { - open: async (options) => { - const journal = await openAgentSessionJournal(options) - opened.push(journal) - return journal - }, - track: (journal) => { - opened.push(journal) - return journal - }, - closeAll: async () => { - await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) - } - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 3637f03dab6..871aca7880d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,17 +13,21 @@ import { boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS } from '../../../shared/agent-session-journal-item-key' -import { loadJournal } from './journal-open' import { boundInlineText, boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { journalDatabaseFile, journalDirectoryFor, journalPathSegment } from './journal-paths' +import { journalDirectoryFor, journalPathSegment } from './journal-paths' import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + deleteTestJournalRow +} from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' const IDENTITY: AgentSessionJournalIdentity = { @@ -54,7 +59,7 @@ const journals = createTrackedJournalOpener() async function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -76,7 +81,10 @@ describe('sequences', () => { const journal = await open() const results = await Promise.all( Array.from({ length: 25 }, (_unused, index) => - journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) ) const sequences = results.map((result) => result.cursor.sequence) @@ -89,9 +97,9 @@ describe('sequences', () => { it('serializes revisions of one item so the last write wins deterministically', async () => { const journal = await open() const results = await Promise.all([ - journal.appendItem(item(0), body('a'), { fence: 1 }), - journal.appendItem(item(0), body('b'), { fence: 1 }), - journal.appendItem(item(0), body('c'), { fence: 1 }) + journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('c'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ]) expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) expect(journal.snapshot().items).toHaveLength(1) @@ -100,9 +108,18 @@ describe('sequences', () => { it('visits reduced items at their creation sequence without promoting an older revision', async () => { const journal = await open() - await journal.appendItem(item(0), body('first'), { fence: 1 }) - const latest = await journal.appendItem(item(1), body('second'), { fence: 1 }) - await journal.appendItem(item(0), body('first revised'), { fence: 1 }) + await journal.appendItem(item(0), body('first'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const latest = await journal.appendItem(item(1), body('second'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('first revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const visited: { itemId: string; sequence: number }[] = [] journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) @@ -131,23 +148,26 @@ describe('sequences', () => { await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - await journal.appendItem(item(0), body('work'), { fence: 1 }) + await journal.appendItem(item(0), body('work'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await bothAgreeOn('turn-1') // The completion is a revision, so it keeps the row's creation sequence rather than moving it. await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'completed' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn(null) await journal.appendItem( turnItem('turn-2'), { kind: 'turn', turnId: 'turn-2', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn('turn-2') }) @@ -165,8 +185,14 @@ describe('sequences', () => { const mimicIdentity = identityFor(digestFormMimic) const journal = await open() - const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) - const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(oversized.itemId).not.toBe(mimic.itemId) expect([oversized.revision, mimic.revision]).toEqual([1, 1]) @@ -187,38 +213,47 @@ describe('sequences', () => { describe('fences', () => { it('rejects an append from a writer behind the journal', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await expect(journal.appendItem(item(1), body('b'), { fence: 6 })).rejects.toBeInstanceOf( - AgentSessionJournalError - ) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect( + journal.appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toBeInstanceOf(AgentSessionJournalError) }) it('keeps accepting appends after a rejected one', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await journal.appendItem(item(1), body('b'), { fence: 6 }).catch(() => undefined) - await journal.appendItem(item(2), body('c'), { fence: 7 }) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal + .appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + .catch(() => undefined) + await journal.appendItem(item(2), body('c'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) }) }) describe('replay', () => { - it('adopts a caller-provided load without replaying the rows again', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) - const loaded = await loadJournal(root, IDENTITY.sessionId) - expect(loaded).not.toBeNull() - await journal.close() - - const reopened = await open({ loaded }) - expect(reopened.snapshot()).toEqual(journal.snapshot()) - }) - it('reopens to the same render model the live writer held', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) - await journal.appendItem(item(1), body('b'), { fence: 1 }) - await journal.appendItem(item(0), body('a2'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('a2'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendTombstone(item(1), { fence: 1 }) const live = journal.snapshot() @@ -228,9 +263,15 @@ describe('replay', () => { it('serves a resume from a cursor and refuses one from a stale epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const cursor = journal.cursor() - await journal.appendItem(item(1), body('b'), { fence: 1 }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const resumed = journal.readSince(cursor) expect(resumed.ok && resumed.rows).toHaveLength(1) @@ -241,7 +282,10 @@ describe('replay', () => { it('rebuilds from a clean epoch after a rollover', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.rollEpoch('unreconcilable_prefix', 2) expect(journal.snapshot().items).toHaveLength(0) @@ -253,12 +297,15 @@ describe('replay', () => { it('keeps the intact prefix and drops the rejected suffix', async () => { const journal = await open() for (let index = 0; index < 4; index += 1) { - await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + await journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const before = journal.epoch await journal.close() await withJournalDatabase(root, (db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(3) + deleteTestJournalRow(db, IDENTITY.sessionId, 3) }) const reopened = await open() @@ -267,8 +314,8 @@ describe('replay', () => { // Sequences 4 and 5 are VALID rows that the gap at 3 made unreplayable. // Nothing preserves them; recovery rebuilds the epoch from provider history. await withJournalDatabase(root, (db) => { - const rows = db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() - expect(rows.map((row) => (row as { seq: number }).seq)).toEqual([1, 2]) + const rows = liveTestJournalRows(db, IDENTITY.sessionId) + expect(rows.map((row) => row.seq)).toEqual([1, 2]) }) expect(reopened.repair).toEqual({ malformedRows: 0 }) }) @@ -306,7 +353,14 @@ describe('lifecycle batches', () => { const input = { settlementId: 'concurrent-settlement', fence: 1, - mutations: [{ kind: 'item' as const, identity: item(1), body: body('settled') }] + mutations: [ + { + kind: 'item' as const, + identity: item(1), + body: body('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] } const [first, replay] = await Promise.all([ @@ -325,17 +379,27 @@ describe('lifecycle batches', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const settled = await journal.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('tool settled') }, + { + kind: 'item', + identity: item(1), + body: body('tool settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'item', identity: { provider: 'orca', clientMessageId: 'exit-status' }, - body: { kind: 'status', text: 'Provider exited' } + body: { kind: 'status', text: 'Provider exited' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }, { kind: 'tombstone', identity: turn } ] @@ -356,7 +420,14 @@ describe('lifecycle batches', () => { const replay = await reopened.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, - mutations: [{ kind: 'item', identity: item(9), body: body('must not appear') }] + mutations: [ + { + kind: 'item', + identity: item(9), + body: body('must not appear'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) expect(replay).toEqual(beforeReplay) expect( @@ -395,14 +466,17 @@ describe('journal location', () => { }) describe('on-disk layout', () => { - it('keeps the session database and its projection in one directory', async () => { + it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { const journal: AgentSessionJournal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) - expect(await readdir(root)).toContain('journal.db') + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(await readdir(root)).toContain('agent-session-journal.db') + expect(await readdir(root)).not.toContain('agent-session-journal') await journal.close() await withJournalDatabase(root, (db) => { - const row = db.prepare('SELECT row_json FROM journal_rows WHERE seq = 2').get() - expect((row as { row_json: string }).row_json).toContain('"kind":"item"') + expect(liveTestJournalRows(db, IDENTITY.sessionId)[1]?.rowJson).toContain('"kind":"item"') expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toMatchObject({ epoch: journal.epoch }) @@ -413,15 +487,14 @@ describe('on-disk layout', () => { /** Opens the session database directly, so a case can stage a fault or read * back what a commit actually stored. */ async function withJournalDatabase( - journalDir: string, + stateDirectory: string, run: (db: Database.Database) => void ): Promise { - const { openJournalDatabase } = await import('./journal-database') - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) + const opened = openTestJournalHostDatabase(stateDirectory) try { run(opened.db) } finally { - opened.db.close() + opened.close() } } @@ -436,7 +509,10 @@ describe('what a handle found on disk when it opened', () => { it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { const earlier = await open() - await earlier.appendItem(item(1), body('one'), { fence: 1 }) + await earlier.appendItem(item(1), body('one'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await earlier.appendSubmission(submission('earlier')) await earlier.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3be3aebbc51..5b046ab421f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,4 +1,5 @@ -// Append-only journal store for one agent session. +// Append-only journal store for one agent session. It owns the chat's fold and write queue, and no +// connection: every statement goes through the host's one journal database. import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' @@ -11,6 +12,7 @@ import type { AgentJournalSubmission, AgentJournalThreadGoal, AgentJournalTurnLifecycle, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -19,14 +21,13 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' import { activeStructuredAgentSessionTurnIdBySequence, + liveStructuredAgentSessionTurnScope, newestStructuredAgentSessionTurnBySequence } from '../../../shared/structured-agent-session-live-turn' -import { agentSessionJournalCloseRetries } from './journal-close-retry' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' import type { JournalReplacementItem } from './journal-epoch-replacement' import { readJournalSince } from './journal-cursor' +import type { JournalHostDatabase } from './journal-host-database' import { readJournalRowsAfterCursor, type JournalLoad } from './journal-open' -import { journalDatabaseFile } from './journal-paths' import { markJournalPendingSubmissionsUnknown, rejectJournalPendingSubmissions, @@ -48,19 +49,21 @@ import type { AgentSessionJournalOptions, JournalAppendResult, JournalItemAppendOptions, + JournalItemLinkageVisitor, JournalLifecycleBatchInput, JournalReadSince, + JournalSubmissionConsume, JournalSubmissionInput, JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' -import { AgentSessionJournalError } from './journal-write-guards' +import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { AgentJournalEpochReason } from './journal-row-schema' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' -import { JournalConnectionCloser, JournalWriteQueue } from './journal-store-close' +import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' -import { ensureJournalDir, journalStoreLoadedFields } from './journal-store-open' +import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' @@ -68,47 +71,42 @@ export { AgentSessionJournalError } from './journal-write-guards' export class AgentSessionJournal { private readonly identity: AgentSessionJournalIdentity - private readonly journalDir: string - private readonly dbPath: string + private readonly database: JournalHostDatabase private readonly now: () => number private readonly mintEpoch: () => string - private readonly loaded: JournalLoad | null | undefined private state: JournalReducerState private readOnly = false private malformedRows = 0 + private openedCorrupt = false private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } - private database: OpenJournalDatabase | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue - private readonly closer: JournalConnectionCloser private readonly rowWriter: JournalRowWriter private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender private readonly restore: () => Promise + /** Draft rows queued while the agent works; never reducer input or owed work. */ + readonly queuedMessages: JournalQueuedMessages constructor(options: AgentSessionJournalOptions) { this.identity = options.identity - this.journalDir = options.journalDir - this.dbPath = journalDatabaseFile(options.journalDir) + this.database = options.database this.now = options.now ?? (() => Date.now()) this.mintEpoch = options.mintEpoch ?? randomUUID - this.loaded = options.loaded this.state = createJournalReducerState(options.identity.sessionId, '') // Serializes sequence assignment with the durable write behind it. this.queue = new JournalWriteQueue(options.identity.sessionId) - this.closer = new JournalConnectionCloser({ - connection: () => this.database?.db ?? null, - enqueue: (run) => this.queue.serializePastGate(run) - }) const collaborators = createJournalStoreCollaborators({ identity: this.identity, - journalDir: this.journalDir, + legacyDirectory: this.database.legacyDirectoryFor(this.identity), now: this.now, mintEpoch: this.mintEpoch, serialize: (run) => this.queue.serialize(run), - database: () => this.requireDatabase(), + deferPerSessionImport: options.deferPerSessionImport === true, + owe: (work) => this.queue.owe(work), + database: () => this.database, state: () => this.state, readOnly: () => this.readOnly, setReadOnly: (readOnly) => { @@ -123,18 +121,22 @@ export class AgentSessionJournal { applyJournalRow(this.state, row) this.onCommitted?.() }, - loaded: () => this.loaded, + setOpenedCorrupt: (corrupt) => { + this.openedCorrupt = corrupt + }, + notifyCommitted: () => this.onCommitted?.(), malformedRows: () => this.malformedRows, setMalformedRows: (count) => { this.malformedRows = count }, journal: () => this, - enqueue: (build) => this.enqueue(build) + enqueue: (build) => this.rowWriter.enqueue(build) }) this.rowWriter = collaborators.rowWriter this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.queuedMessages = collaborators.queuedMessages this.restore = collaborators.restore } @@ -146,10 +148,6 @@ export class AgentSessionJournal { return this.state.epoch } - get directory(): string { - return this.journalDir - } - /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ wroteBeforeOpen(sequence: number | undefined): boolean { @@ -165,27 +163,21 @@ export class AgentSessionJournal { return { malformedRows: this.malformedRows } } - async open(): Promise { - await ensureJournalDir(this.journalDir) - this.database = openJournalDatabase(this.dbPath) - try { - await this.restore() - this.openedThrough = this.cursor() - } catch (error) { - // Nothing else holds a reference to this connection, so a throw here is - // the leak site unless the store releases it itself — and a close that - // REJECTS has not released it, so the store is retained for a later retry - // instead of being dropped with its handle open. - await agentSessionJournalCloseRetries.closeOrRetain(this) - throw error - } + /** The open replayed an unusable prefix: the chat is owed a rebuild from provider history. */ + get needsRebuild(): boolean { + return this.openedCorrupt } - /** Releases the session's SQLite handle. Idempotent on success, a real retry - * after a failure, and permanently closed to writes either way (§ close). */ + async open(): Promise { + await this.restore() + this.openedThrough = this.cursor() + } + + /** Refuses every later write and resolves once the admitted ones have landed. Holds no + * connection, so there is nothing to release and nothing that can fail. */ close(): Promise { this.queue.markClosed() - return this.closer.close() + return this.queue.drain() } /** Told of every durable change, epoch replacements included, so a reader learns of a write @@ -194,6 +186,19 @@ export class AgentSessionJournal { this.onCommitted = listener } + /** + * Resolves once the chat's rows are in the host's database. A restore's open serves a chat still + * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write, + * and a reader that needs rows (forward pages, catch-up) awaits it here. + */ + whenImported(): Promise { + return this.queue.serialize(async () => undefined) + } + + get importPending(): boolean { + return this.queue.owing + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -214,11 +219,23 @@ export class AgentSessionJournal { itemBody = (itemId: string): AgentJournalItemBody | null => this.state.items.get(itemId)?.body ?? null + /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an + * earlier run of this session left. */ + visitItemsWithLinkage = (visit: JournalItemLinkageVisitor): void => { + for (const item of this.state.items.values()) { + visit(item.itemId, item.sequence, item.body, item) + } + } + /** The turn this journal has published as running — the same read a client's snapshot gives, * without materialising one. */ activeTurnId = (): string | null => activeStructuredAgentSessionTurnIdBySequence(this.state.items.values()) + /** Where a row written now belongs: the running turn, or the conversation. */ + liveTurnScope = (): AgentJournalTurnScope => + liveStructuredAgentSessionTurnScope(this.state.items.values()) + /** The newest turn record whatever state it settled in, for readers that need the outcome. */ newestTurn = (): AgentJournalTurnLifecycle | null => newestStructuredAgentSessionTurnBySequence(this.state.items.values()) @@ -239,6 +256,8 @@ export class AgentSessionJournal { submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] + submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId) + pendingSubmissions = (): AgentJournalSubmission[] => this.submissions().filter((entry) => entry.dispatchState === 'pending') @@ -255,7 +274,7 @@ export class AgentSessionJournal { state: this.state, rowsAfter: (afterSequence) => readJournalRowsAfterCursor( - this.requireDatabase().db, + this.database.db, this.identity.sessionId, this.state.epoch, afterSequence, @@ -273,7 +292,7 @@ export class AgentSessionJournal { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions = { fence: 0 } + options: JournalItemAppendOptions ): Promise { return this.itemAppender.append(identity, body, options) } @@ -283,8 +302,8 @@ export class AgentSessionJournal { options: JournalTombstoneInput ): Promise { const itemId = agentJournalItemKey(identity) - return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then( - (row) => ({ epoch: row.epoch, sequence: row.seq }) + return this.rowWriter.append( + journalTombstoneRowBuilder(() => this.state, itemId, options.fence) ) } @@ -297,10 +316,16 @@ export class AgentSessionJournal { * anything, and it doubles as the optimistic user bubble so an accepted echo * reconciles into an existing slot instead of appending a second copy. */ - appendSubmission(input: JournalSubmissionInput): Promise { - return this.enqueue( - journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input) - ).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + appendSubmission( + input: JournalSubmissionInput, + /** Present: this submission is a queued draft's conversion, and the draft's + * state transition commits in the SAME transaction — exactly-once consume. */ + consume?: JournalSubmissionConsume + ): Promise { + return this.rowWriter.append( + journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + ) } /** @@ -311,10 +336,7 @@ export class AgentSessionJournal { * string here would silently give the user a second copy of their own message. */ resolveDispatch(input: ResolveDispatchInput): Promise { - return this.enqueue(journalDispatchRowBuilder(() => this.state, input)).then((row) => ({ - epoch: row.epoch, - sequence: row.seq - })) + return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input)) } /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ @@ -356,23 +378,4 @@ export class AgentSessionJournal { private adoptLoadedJournal(loaded: JournalLoad): void { Object.assign(this, journalStoreLoadedFields(loaded)) } - - private requireDatabase(): OpenJournalDatabase { - if (!this.database) { - throw new AgentSessionJournalError( - 'journal_closed', - `agent-session journal for ${this.identity.sessionId} is not open` - ) - } - return this.database - } - - /** - * Assign the next sequence, make the row durable, and fold it through the - * SAME reducer replay uses — all inside one serialized step, so concurrent - * callers cannot interleave and mint the same sequence. - */ - private enqueue(build: (seq: number, ts: number) => JournalRow): Promise { - return this.rowWriter.enqueue(build) - } } diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts index 0f1490c3ca8..c000ffe9fba 100644 --- a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts @@ -3,15 +3,19 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' +import type { JournalHostDatabase } from './journal-host-database' import { replayJournal } from './journal-open' -import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import * as reducer from './journal-reducer' +import { + openTestJournalHostDatabase, + insertTestJournalRow, + publishTestJournalEpoch, + insertTestJournalRowJson +} from './journal-host-database-test-support' let root: string -let opened: OpenJournalDatabase +let opened: JournalHostDatabase const sessionId = 'streaming-session' const epoch = 'epoch-1' @@ -43,18 +47,18 @@ function revision(seq: number, text = 'content'): JournalRow { } function put(row: JournalRow): void { - insertJournalRow(opened.db, sessionId, row) + insertTestJournalRow(opened.db, sessionId, row) } beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-stream-replay-')) - opened = openJournalDatabase(journalDatabaseFile(root)) - upsertJournalSessionRow(opened.db, sessionId, epoch, 1) + opened = openTestJournalHostDatabase(root) + publishTestJournalEpoch(opened.db, sessionId, epoch) }) afterEach(async () => { vi.restoreAllMocks() - opened.db.close() + opened.close() await rm(root, { recursive: true, force: true }) }) @@ -86,7 +90,7 @@ describe('streaming journal replay', () => { } apply(state, row) }) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded.state.items.size).toBe(1) expect(loaded.state.items.get('message-1')?.revision).toBe(2049) expect(loaded.state.lastSequence).toBe(2049) @@ -108,7 +112,7 @@ describe('streaming journal replay', () => { } apply(state, row) }) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded.state.lastSequence).toBe(300) expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0]) }) @@ -118,7 +122,7 @@ describe('streaming journal replay', () => { put(revision(2)) put(revision(4)) put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 }) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 }) expect(loaded.truncateFrom).toBeUndefined() expect(loaded.state.items.get('message-1')?.revision).toBe(2) @@ -131,10 +135,8 @@ describe('streaming journal replay', () => { put(anchor()) put(revision(2)) put(revision(4)) - opened.db - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(sessionId, epoch, 5, 5, '{') - const loaded = replayJournal(opened.db, false, sessionId)! + insertTestJournalRowJson(opened.db, sessionId, 5, '{') + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: false, corrupt: true, @@ -147,7 +149,7 @@ describe('streaming journal replay', () => { it('rejects an unanchored prefix before a later gap', () => { put(revision(1)) put(revision(3)) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 }) expect(loaded.state.items.size).toBe(0) expect(loaded.state.lastSequence).toBe(0) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index b0dff559b1a..6b519ce35cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -18,7 +19,7 @@ import { codexSubagentGroupIdentity } from '../../codex/codex-subagent-roster' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' const IDENTITY: AgentSessionJournalIdentity = { @@ -44,7 +45,7 @@ const journals = createTrackedJournalOpener() async function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -222,7 +223,10 @@ describe('journal reopen after the writing host is gone', () => { { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } ]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // Still the writing host: it can see the children, so the row says so. const beforeRestart = live.snapshot().items.at(-1)! @@ -245,7 +249,10 @@ describe('journal reopen after the writing host is gone', () => { it('revises the row in place rather than appending a second one', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const before = live.snapshot().items.length await live.close() @@ -257,7 +264,10 @@ describe('journal reopen after the writing host is gone', () => { it('settles a persisted working background task to unverifiable', async () => { const live = await open() const row = backgroundTaskRow() - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const beforeRestart = live.snapshot().items.at(-1)! expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') @@ -273,7 +283,10 @@ describe('journal reopen after the writing host is gone', () => { it('writes nothing on a second reopen once every child is settled', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await live.close() const once = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index f3868eedcf4..ec81136688e 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -9,8 +9,9 @@ // `unverifiable`, never a synthesized exit — see // `docs/reference/ssh-execution-boundary.md`. // -// Reconciles JOURNAL ROWS, not roster state: nothing here seeds the producer's -// in-process group map, so the roster's known limitation is untouched. +// Reconciles JOURNAL ROWS, not roster state. A producer that continues a roster +// from the journal reads these rows after this revision, so it inherits the +// verdict rather than the dead host's `working`. import { agentJournalItemKey, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts new file mode 100644 index 00000000000..2c9c6cfcd04 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -0,0 +1,113 @@ +// Folding submission rows: the queue entry, its message row, where a handover places it, and the +// provider item an accepted message adopts. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { journalRenderItem } from './journal-render-item' +import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import { journalDispatchRowApplies } from './journal-dispatch-settlement' + +export function applyJournalSubmission( + state: JournalReducerState, + row: Extract +): void { + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), + // A malformed stored link is dropped, never the row. + ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 + ? { queuedMessageId: row.queuedMessageId } + : {}), + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) + }) + const itemId = agentJournalSubmissionKey(row.clientMessageId) + // A message handed over later belongs to no turn until its handover names one. + const turnScope = row.handoverRecorded + ? AGENT_JOURNAL_THREAD_SCOPE + : statedOrDerivedTurnScope(state, row) + upsertJournalItem( + state, + itemId, + 0, + journalRenderItem(itemId, 0, row.body, row, turnScope), + row.fence + ) +} + +/** A queued message joins the conversation where it was handed over, not where it was accepted: + * what the agent did meanwhile — a command it waited behind, say — happened before it. It joins + * the turn that handover delivered it into — a steer — or none. Rows from hosts that predate the + * stated scope are scoped at the handover, as their creation would have been. */ +export function placeHandedOverMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (!submission.handoverRecorded || !item) { + return + } + const { sequenceIndex: _acceptedAt, ...accepted } = item + state.items.set(itemId, { + ...accepted, + sequence: row.seq, + observedAt: row.ts, + turnScope: row.turnScope ?? state.derivedTurnScope.scopeFor(item.body) + }) +} + +export function acceptSubmissionFromProviderItem( + state: JournalReducerState, + providerItemId: string, + resolvedItemId: string, + row: Pick +): void { + if (providerItemId === resolvedItemId) { + return + } + const submission = [...state.submissions.values()].find( + (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId + ) + if (!submission || !journalDispatchRowApplies(submission)) { + return + } + submission.fence = row.fence + submission.dispatchState = 'accepted' + notePersonTurnAccepted(state, submission) + submission.providerItemId = providerItemId + submission.reason = null + submission.resolvedAt = row.ts + delete submission.recovered + state.receipts.set(submission.clientMessageId, { + clientMessageId: submission.clientMessageId, + providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */ +export function notePersonTurnAccepted( + state: JournalReducerState, + submission: Pick +): void { + if (submission.origin === 'client' && submission.acceptedSequence !== undefined) { + state.latestPersonTurnSequence = Math.max( + state.latestPersonTurnSequence, + submission.acceptedSequence + ) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts new file mode 100644 index 00000000000..fc943164615 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -0,0 +1,154 @@ +// A submission that hands off a queued draft names that draft (`queuedMessageId`), +// persisted on its journal row and published on the submission; a direct send +// names none. Clients read the link, never a draft id compared with a submission id. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-schemas' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, applyJournalRow } from './journal-reducer' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) { + await journal.queuedMessages.insert({ + messageId: draftId, + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await journal.appendSubmission( + { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: draftId, expect: 'waiting', settledByOp: null } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-link-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the submission names the queued draft it hands off', () => { + it('on every hand-off, and never on a direct send', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.appendSubmission({ + clientMessageId: 'direct-1', + payloadFingerprint: 'fp-direct', + body: BODY, + fence: 0 + }) + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect(journal.submission('direct-1')).not.toHaveProperty('queuedMessageId') + }) + + it('survives a reload, which replays the rows through the reducer', async () => { + let journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.close() + journal = await open() + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect( + journal.snapshot().submissions.find((entry) => entry.clientMessageId === 'handoff-1') + ).toMatchObject({ queuedMessageId: 'draft-1' }) + }) + + it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await expect( + journal.appendSubmission( + { + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + body: BODY, + fence: 0, + queuedMessageId: 'draft-2' + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('is published: the wire schema keeps the field rather than stripping it', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + const published = AgentJournalSubmissionSchema.parse(journal.submission('handoff-1')) + expect(published.queuedMessageId).toBe('draft-1') + }) +}) + +describe('the persisted row', () => { + const row: JournalRow = { + v: 1, + kind: 'submission', + epoch: 'epoch-1', + seq: 1, + fence: 0, + ts: 1, + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + providerHandle: IDENTITY.providerHandle, + body: BODY, + queuedMessageId: 'draft-1' + } + + it('parses with the key, which a reader that does not know it simply keeps', () => { + const parsed = parseJournalRow(serializeJournalRow(row)) + expect(parsed).toMatchObject({ ok: true, row: { queuedMessageId: 'draft-1' } }) + }) + + it('keeps a row whose stored link is malformed, dropping only the link', () => { + const parsed = parseJournalRow(JSON.stringify({ ...row, queuedMessageId: 42 })) + if (!parsed.ok) { + throw new Error('the row must survive a malformed link') + } + const state = createJournalReducerState('session-q', 'epoch-1') + applyJournalRow(state, parsed.row) + expect(state.submissions.get('handoff-1')).not.toHaveProperty('queuedMessageId') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts new file mode 100644 index 00000000000..2b8e4b80a76 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import { + buildJournalItemRow, + buildJournalSubmissionRow, + journalRowBase +} from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +const TURN_1: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-1' } +const TURN_2: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-2' } +const COMMAND_TURN: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const inTurn = (identity: AgentJournalItemIdentity): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: agentJournalItemKey(identity) +}) + +function row(id: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: id } +} + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function turn(turnId: string, state: 'running' | 'completed'): AgentJournalItemBody { + return { kind: 'turn', turnId, state, startedAt: 1 } +} + +/** Builds rows against a scratch state, so the same list can be folded more than once. */ +function rows() { + const state = createJournalReducerState('session-1', 'epoch-1') + const built: JournalRow[] = [] + let seq = 0 + const push = (next: JournalRow): void => { + built.push(next) + applyJournalRow(state, next) + } + return { + state, + built, + item( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + scope?: AgentJournalTurnScope + ) { + seq += 1 + const next = buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + turnScope: scope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + if (!scope) { + // A row from a host that predates stated scopes. + delete next.turnScope + } + push(next) + }, + submission(clientMessageId: string) { + seq += 1 + push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + handoverRecorded: true + }) + ) + }, + handover(clientMessageId: string, scope?: AgentJournalTurnScope) { + seq += 1 + // Built by hand: a row from an older host carries no scope, which the builder never writes. + push({ + kind: 'dispatch', + clientMessageId, + state: 'pending', + providerItemId: null, + reason: null, + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + ...(scope ? { turnScope: scope } : {}) + }) + } + } +} + +function scopeOf(state: JournalReducerState, identity: AgentJournalItemIdentity) { + return state.items.get(agentJournalItemKey(identity))?.turnScope +} + +function submissionScope(state: JournalReducerState, clientMessageId: string) { + return state.items.get(agentJournalSubmissionKey(clientMessageId))?.turnScope +} + +describe('stated turn scope', () => { + it('keeps the scope of the write that created the row', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + item(row('answer'), prose('first'), inTurn(TURN_1)) + // A revision stating another scope only updates the content. + item(row('answer'), prose('second'), AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('answer'))).toEqual(inTurn(TURN_1)) + }) + + it('places a queued message only when its handover states a turn', () => { + const { state, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('steer') + submission('later') + // Accepted while a turn runs, but not yet delivered into it. + expect(submissionScope(state, 'steer')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + handover('steer', inTurn(TURN_1)) + handover('later', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(submissionScope(state, 'later')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a message held behind a command out of the command turn', () => { + const { state, item, submission, handover } = rows() + submission('cmd-1') + handover('cmd-1', AGENT_JOURNAL_THREAD_SCOPE) + item(COMMAND_TURN, turn('compact:cmd-1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('held') + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + item(row('result'), prose('compacted'), inTurn(COMMAND_TURN)) + item(COMMAND_TURN, turn('compact:cmd-1', 'completed'), AGENT_JOURNAL_THREAD_SCOPE) + const drawn = (messages: readonly { id: string }[]) => + messages + .map((message) => message.id) + .filter((id) => id === agentJournalItemKey(row('result')) || id.includes('held')) + const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] + const onPhone = () => { + const { items, submissions } = renderJournalState(state) + return projectStructuredAgentSessionMessages(items, [], submissions) + } + // Still waiting: drawn after everything the agent did, the command's result included. + expect(drawn(onPhone())).toEqual(expected) + handover('held', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + // Delivered: placed where it was handed over, by sequence for paging and the phone, and by + // timestamp for the desktop transcript. + expect(drawn(onPhone())).toEqual(expected) + expect(drawn(projectNativeChatTranscriptMessages(onPhone()))).toEqual(expected) + }) +}) + +describe('scope derived for rows stored without one', () => { + it('places rows inside a live span and closes it at the terminal revision', () => { + const { state, item } = rows() + item(row('before'), prose('before')) + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(scopeOf(state, row('before'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, TURN_1)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('inside'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a turn rebuilt already settled open until the next turn record', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'completed')) + item(row('first'), prose('first')) + item(TURN_2, turn('t2', 'completed')) + item(row('second'), prose('second')) + expect(scopeOf(state, row('first'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('second'))).toEqual(inTurn(TURN_2)) + }) + + it('closes a legacy /compact carrier where its turn body is overwritten, and shows the carrier', () => { + const { state, item } = rows() + const carrier = row('compact:op-1') + item(carrier, { + kind: 'status', + text: 'Compacting conversation…', + turnLifecycle: { turnId: 'compact:op-1', state: 'running' } + }) + item(row('summary'), prose('summary')) + item(carrier, { kind: 'status', text: 'Conversation compacted.' }) + item(row('after'), prose('after')) + expect(scopeOf(state, carrier)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('summary'))).toEqual(inTurn(carrier)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('lands rows written after a crash left a turn running in that turn', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('crash-window'), prose('written before the sweep')) + expect(scopeOf(state, row('crash-window'))).toEqual(inTurn(TURN_1)) + }) + + it('places a queued message handed over without a stated scope in the live turn', () => { + const { state, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + submission('steer') + handover('steer') + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts index e1869ae0c73..10e709d174f 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -5,10 +5,15 @@ export class AgentSessionJournalError extends Error { constructor( - readonly code: 'journal_read_only' | 'journal_stale_fence' | 'journal_closed', - message: string + readonly code: + | 'journal_read_only' + | 'journal_stale_fence' + | 'journal_closed' + | 'journal_submission_exists', + message: string, + options?: ErrorOptions ) { - super(message) + super(message, options) this.name = 'AgentSessionJournalError' } } @@ -34,3 +39,17 @@ export function assertJournalFence(fence: number, highestFence: number): void { ) } } + +/** One id, one delivery: a second submission row under an id would reset its + * settled answer to pending and hand the message over again. */ +export function assertSubmissionIdUnused( + submissions: ReadonlyMap, + clientMessageId: string +): void { + if (submissions.has(clientMessageId)) { + throw new AgentSessionJournalError( + 'journal_submission_exists', + `a submission ${clientMessageId} is already recorded` + ) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts new file mode 100644 index 00000000000..13fd9e4b96c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -0,0 +1,61 @@ +// One chat's write queue: build, commit and adopt run one at a time, in arrival order. +// +// Closing is admission only. A closed store refuses new writes forever, so a stale reference +// (an event sink, a late callback) can never append from a fold a newer open has replaced; the +// writes already admitted still land, and `drain` resolves once they have. + +import { AgentSessionJournalError } from './journal-write-guards' + +/** Admission is checked at ENQUEUE and is permanent. */ +export class JournalWriteQueue { + private writes: Promise = Promise.resolve() + private closed = false + /** Runs before the next write, and stays owed until it succeeds. */ + private owed: (() => Promise) | null = null + + constructor(private readonly sessionId: string) {} + + markClosed(): void { + this.closed = true + } + + serialize(run: () => Promise): Promise { + if (this.closed) { + return Promise.reject( + new AgentSessionJournalError( + 'journal_closed', + `agent-session journal for ${this.sessionId} is closed` + ) + ) + } + return this.serializePastGate(run) + } + + owe(work: () => Promise): void { + this.owed = work + } + + get owing(): boolean { + return this.owed !== null + } + + private payOwed = async (): Promise => { + const owed = this.owed + if (owed) { + await owed() + this.owed = null + } + } + + /** Resolves once every write admitted so far has settled, whatever its outcome. */ + drain(): Promise { + return this.writes.then(() => undefined) + } + + private serializePastGate(run: () => Promise): Promise { + // Only a write admitted while work is owed takes the extra step, so no other write's timing moves. + const started = this.owed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) + this.writes = started.catch(() => undefined) + return started + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts new file mode 100644 index 00000000000..638a62bdf02 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -0,0 +1,218 @@ +// Draft bookkeeping never vetoes a journal row: a failing draft transition +// rolls back alone and the next open re-derives it, and a draft table an +// earlier build created gains the columns this build writes. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from './journal-host-database' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const REFUSAL = agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } + }), + { surface: 'rejection' } +) + +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] + } + await journal.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await journal.appendSubmission( + { + clientMessageId: `sub-${messageId}`, + payloadFingerprint: `fp-${messageId}`, + body, + fence: 0, + handoverRecorded: true + }, + { messageId, expect: 'waiting', settledByOp: null } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-')) + clock = 1_000 +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('draft bookkeeping inside a journal append', () => { + it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => { + let journal = await open() + await queueAndConsume(journal, 'draft-1') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => { + throw new Error('table queued_messages has no column named returned_rejection') + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + // The journal's own answer stands: Stop, failed starts and refusals depend on it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + expect(warn).toHaveBeenCalledWith( + '[journal-append] row bookkeeping skipped:', + expect.objectContaining({ kind: 'dispatch' }) + ) + await journal.close() + journal = await open() + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedReason: REFUSAL.reason, + returnedRejection: { kind: 'providerRejected' } + }) + }) + + it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { + const first = await open() + await first.close() + // The table is healed when the host opens its database, so the host restarts around the edit. + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.exec('DROP TABLE queued_messages') + // The shape an earlier build of the draft table wrote: no returned_rejection. + db.exec(`CREATE TABLE queued_messages ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL, + body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT, + returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, + PRIMARY KEY (session_id, message_id))`) + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + await queueAndConsume(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedRejection: { kind: 'providerRejected' } + }) + }) + + describe('a failed COMMIT', () => { + /** The append's own COMMIT fails once, after its hooks ran. */ + function failNextCommit() { + const exec = Database.prototype.exec + let armed = true + return vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database, + sql: string + ) { + if (armed && sql === 'COMMIT') { + armed = false + throw new Error('SQLITE_FULL') + } + return exec.call(this, sql) + }) + } + + async function consumeFailingCommit(journal: AgentSessionJournal): Promise { + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp-draft-1', + hostInstance: 'proc-1' + }) + expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) + const commit = failNextCommit() + try { + await expect( + journal.appendSubmission( + { clientMessageId: 'sub-draft-1', payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow('SQLITE_FULL') + } finally { + commit.mockRestore() + } + } + + it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => { + const journal = await open() + const list = vi.spyOn(JournalQueuedMessages.prototype, 'list') + await consumeFailingCommit(journal) + // Once by the test itself before the append; never inside it. + expect(list).toHaveBeenCalledTimes(1) + list.mockRestore() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + + it('invalidates what any read inside the rolled-back transaction cached', async () => { + const journal = await open() + // Some other bookkeeping reads the list inside the transaction, after the consume wrote. + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function ( + this: JournalQueuedMessages + ) { + this.list() + }) + await consumeFailingCommit(journal) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts new file mode 100644 index 00000000000..5b21c36f3af --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -0,0 +1,206 @@ +// A draft sent back to waiting after a handed-over hand-off was rejected as +// never delivered is withdrawn when the provider echoes that message: the +// first send reached the agent, so sending it again would repeat it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' +}) + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function echo(journal: AgentSessionJournal, uuid: string, text: string) { + return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) +} + +/** A draft consumed and handed to the agent, then rejected as never delivered (the provider + * confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before + * hand-over instead, which proves it was never written. */ +async function withdrawnDraft( + text: string, + options: { handedOver: boolean } = { handedOver: true } +): Promise { + const journal = await open() + await handOffAndReject(journal, text, options) + return journal +} + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function handOffAndReject( + journal: AgentSessionJournal, + text: string, + options: { handedOver: boolean } = { handedOver: true } +): Promise { + const body = message(text) + const fingerprint = queuedMessageFingerprint(IDENTITY.sessionId, body) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint, + hostInstance: 'p' + }) + await journal.appendSubmission( + { + clientMessageId: 'sub-draft-1', + payloadFingerprint: fingerprint, + body, + fence: 0, + handoverRecorded: true + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + if (options.handedOver) { + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'pending', + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...STOP_WITHDRAWAL, + fence: 0 + }) + } else { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("a waiting draft whose 'never delivered' claim an echo disproves", () => { + it('is withdrawn when the provider echoes the message it was withdrawn from', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + settledByOp: null + }) + // The rejection stays terminal: the echo is kept apart, not folded into it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) + }) + + it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => { + const journal = await withdrawnDraft('did it land?', { handedOver: false }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('retires the pause in the per-row hook when that echo withdraws the last card it holds back', async () => { + const journal = await withdrawnDraft('did it land?') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it('stays waiting for an echo of some other text', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'something else') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('stays waiting when a live send of the same text claims the echo', async () => { + const journal = await withdrawnDraft('did it land?') + const body = message('did it land?') + await journal.appendSubmission({ + clientMessageId: 'typed-again', + payloadFingerprint: queuedMessageFingerprint(IDENTITY.sessionId, body), + body, + fence: 0, + handoverRecorded: true + }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.submission('typed-again')?.dispatchState).toBe('accepted') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + describe('when the per-row hook was skipped', () => { + it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => { + let journal = await withdrawnDraft('did it land?') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await echo(journal, 'echo-1', 'did it land?') + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true) + // The drain's heal, before the draft could send again. + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('an unclaimed echo from before the hand-off proves nothing about it', async () => { + const journal = await open() + await echo(journal, 'typed-in-the-agent', 'did it land?') + await handOffAndReject(journal, 'did it land?') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts new file mode 100644 index 00000000000..f111b49037f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts @@ -0,0 +1,128 @@ +// A draft sent back to waiting rests on its submission's "never delivered" +// claim. The provider echoing that message proves the claim wrong: the first +// delivery happened. The reducer keeps such an echo apart (a rejected +// submission may not claim it), so it is read here, from the row itself. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + isProviderUserMessageEcho, + journalEchoClaimant, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import type { QueuedMessageRow } from './queued-message-table' + +/** The waiting draft this appended row proves was delivered, or null. Called + * before the row applies, for every row, so a row that is no new provider + * echo of a user message returns before anything else is read. */ +export function draftDeliveredByEcho( + state: JournalReducerState, + drafts: () => readonly QueuedMessageRow[], + row: JournalRow +): string | null { + const echoes = appendedItems(row).filter( + (item) => + isProviderUserMessageEcho(item.itemId, item.body) && + !state.items.has(item.itemId) && + !state.aliases.has(item.itemId) && + journalEchoClaimant(state, item.itemId, item.body) === null + ) + if (echoes.length === 0) { + return null + } + const spent = spentWaitingDrafts(state, drafts()) + for (const item of echoes) { + const delivered = spent.find((draft) => echoProvesDelivered(state, draft, item.body, row.seq)) + if (delivered) { + return delivered.draft.messageId + } + } + return null +} + +/** + * The re-derivation behind that per-row hook, which is bookkeeping and may be + * skipped: waiting drafts an echo ALREADY in the journal proves delivered. An + * unclaimed echo is the item still stored under its provider id — a claimed one + * was folded into its submission's item. Reads every item, so callers run it + * only where a draft is about to send, never per streamed row. + */ +export function draftsDeliveredByAppliedEcho( + state: JournalReducerState, + drafts: readonly QueuedMessageRow[] +): string[] { + const spent = spentWaitingDrafts(state, drafts) + if (spent.length === 0) { + return [] + } + const delivered = new Set() + for (const item of state.items.values()) { + if (!isProviderUserMessageEcho(item.itemId, item.body)) { + continue + } + for (const candidate of spent) { + if (echoProvesDelivered(state, candidate, item.body, item.sequence)) { + delivered.add(candidate.draft.messageId) + } + } + } + return [...delivered] +} + +type SpentDraft = { draft: QueuedMessageRow; since: number } + +/** Waiting drafts some hand-off of which was handed over, then rejected as never delivered; + * `since` is the earliest such hand-off's row. A hand-off rejected before hand-over is + * provably unwritten: an echo matching it is some other message, and must not delete the card. */ +function spentWaitingDrafts( + state: JournalReducerState, + drafts: readonly QueuedMessageRow[] +): SpentDraft[] { + const since = new Map() + for (const submission of state.submissions.values()) { + if ( + submission.queuedMessageId !== undefined && + submission.dispatchState === 'rejected' && + submission.handedOverAt !== undefined + ) { + const sequence = submission.acceptedSequence ?? 0 + const earliest = since.get(submission.queuedMessageId) + since.set(submission.queuedMessageId, Math.min(earliest ?? sequence, sequence)) + } + } + return drafts.flatMap((draft) => { + const from = since.get(draft.messageId) + return draft.state === 'waiting' && from !== undefined ? [{ draft, since: from }] : [] + }) +} + +/** The one predicate both paths share: an unclaimed echo appended after a disproved hand-off, + * carrying the draft's own payload. */ +function echoProvesDelivered( + state: JournalReducerState, + spent: SpentDraft, + body: AgentJournalItemBody, + sequence: number +): boolean { + return ( + sequence > spent.since && + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: state.sessionId, + fields: { body } + }) === spent.draft.fingerprint + ) +} + +function appendedItems(row: JournalRow): { itemId: string; body: AgentJournalItemBody }[] { + if (row.kind === 'item') { + return [{ itemId: row.itemId, body: row.body }] + } + if (row.kind === 'lifecycle-batch') { + return row.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [{ itemId: mutation.itemId, body: mutation.body }] : [] + ) + } + return [] +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts new file mode 100644 index 00000000000..665466d60e7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -0,0 +1,49 @@ +// Per-draft holds: what keeps one card from auto-sending, stored on its row. A +// Stop or a restart pauses the whole queue instead (`queued-message-pause-table.ts`, +// and the host instance each row records); a per-draft hold is only a +// conversion that failed, which an explicit Send releases. + +import type Database from '../../sqlite/sync-database' +import type { QueuedMessageHoldReason } from './queued-message-table' + +/** Hold waiting drafts from auto-sending. The hold retires with the row: consume + * and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */ +export function holdQueuedMessages( + db: Database.Database, + input: { + sessionId: string + messageIds: readonly string[] + reason: QueuedMessageHoldReason + } +): number { + const update = db.prepare( + `UPDATE queued_messages SET hold_reason = ? + WHERE session_id = ? AND message_id = ? AND state = 'waiting' + AND (hold_reason IS NULL OR hold_reason <> ?)` + ) + let held = 0 + for (const messageId of input.messageIds) { + held += Number(update.run(input.reason, input.sessionId, messageId, input.reason).changes ?? 0) + } + return held +} + +/** Ends a restart's pause: waiting rows another host instance wrote are adopted + * into this one, the same fact the pause is derived from, so no second copy + * exists. Also clears a per-row 'stopped' hold an earlier build of the queue + * wrote, which this build only ever lifts. Returns how many rows it changed. */ +export function adoptQueuedMessages( + db: Database.Database, + input: { sessionId: string; hostInstance: string } +): number { + return Number( + db + .prepare( + `UPDATE queued_messages + SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END + WHERE session_id = ? AND state = 'waiting' + AND (host_instance <> ? OR hold_reason = 'stopped')` + ) + .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts new file mode 100644 index 00000000000..41387165de0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts @@ -0,0 +1,161 @@ +// The queue-level pause fact: where in the journal the user's last Stop (or a +// /clear, which starts its replacement paused) took effect. The pause itself is never stored — it is derived from this fact and +// the journal rows after it (a user-requested turn that started ends it); the +// fact only records the one event the journal's closed row kinds cannot carry. +// An explicit Resume retires it. + +import type Database from '../../sqlite/sync-database' + +export type QueuePauseReason = 'stopped' | 'cleared' + +export type QueuePauseFact = { + reason: QueuePauseReason + /** The journal position the Stop took effect at: rows after it are later. */ + epoch: string + sequence: number + recordedAt: number +} + +export function readQueuePause(db: Database.Database, sessionId: string): QueuePauseFact | null { + const row: unknown = db + .prepare( + 'SELECT reason, epoch, sequence, recorded_at FROM queued_message_pauses WHERE session_id = ?' + ) + .get(sessionId) + if ( + typeof row !== 'object' || + row === null || + !('reason' in row) || + (row.reason !== 'stopped' && row.reason !== 'cleared') || + !('epoch' in row) || + typeof row.epoch !== 'string' || + !('sequence' in row) || + typeof row.sequence !== 'number' || + !('recorded_at' in row) || + typeof row.recorded_at !== 'number' + ) { + // A reason this build cannot place reads as no pause rather than a wrong one. + return null + } + return { + reason: row.reason, + epoch: row.epoch, + sequence: row.sequence, + recordedAt: row.recorded_at + } +} + +/** The latest Stop replaces an earlier one: only the last interruption decides. */ +export function recordQueuePause( + db: Database.Database, + input: { sessionId: string; fact: QueuePauseFact } +): void { + db.prepare( + `INSERT INTO queued_message_pauses (session_id, reason, epoch, sequence, recorded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (session_id) DO UPDATE SET + reason = excluded.reason, epoch = excluded.epoch, + sequence = excluded.sequence, recorded_at = excluded.recorded_at` + ).run( + input.sessionId, + input.fact.reason, + input.fact.epoch, + input.fact.sequence, + input.fact.recordedAt + ) +} + +/** Compare-and-clear: only the fact the caller judged, so a Stop recorded since stands. */ +export function clearQueuePause( + db: Database.Database, + input: { sessionId: string; fact: Pick } +): number { + return Number( + db + .prepare( + 'DELETE FROM queued_message_pauses WHERE session_id = ? AND epoch = ? AND sequence = ?' + ) + .run(input.sessionId, input.fact.epoch, input.fact.sequence).changes ?? 0 + ) +} + +type QueueCardState = { state: string; holdReason: string | null } + +/** A card a pause holds back: waiting, with no hold of its own, wherever it sits. + * While one exists — or a hand-off still owed a return to waiting + * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is + * retired only once none remains, so deleting a returned card that blocks such + * cards leaves them paused rather than sending them unasked. */ +export function isPausableQueuedMessage(row: QueueCardState): boolean { + return row.state === 'waiting' && row.holdReason === null +} + +/** Whether Resume would send anything: a pausable card not behind a returned one, + * which blocks everything after it until the user acts, exactly as the drain + * reads it. Only then is the kept pause PUBLISHED, so its header never offers a + * Resume that sends nothing. */ +export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): boolean { + for (const row of rows) { + if (row.state === 'returned') { + return false + } + if (isPausableQueuedMessage(row)) { + return true + } + } + return false +} + +/** What a queue pause holds back, judged inside the caller's transaction: a waiting + * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched + * one whose settlement back to waiting is still owed — its hook was skipped, so the + * row has not caught up with its rejected submission, which only the journal's + * submissions can tell (`owedToWaiting`). */ +export function queuePauseHoldsBack( + db: Database.Database, + input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } +): boolean { + const pausable = db + .prepare( + `SELECT 1 FROM queued_messages + WHERE session_id = ? AND state = 'waiting' AND hold_reason IS NULL LIMIT 1` + ) + .get(input.sessionId) + if (pausable !== undefined) { + return true + } + return db + .prepare( + `SELECT consumed_as FROM queued_messages + WHERE session_id = ? AND state = 'dispatched' AND consumed_as IS NOT NULL` + ) + .all(input.sessionId) + .some( + (row) => + typeof row === 'object' && + row !== null && + 'consumed_as' in row && + typeof row.consumed_as === 'string' && + input.owedToWaiting(row.consumed_as) + ) +} + +/** A pause is over the cards it paused: once it holds back none (`queuePauseHoldsBack`), + * the fact goes too, in the same transaction as the write that took the last one, so + * it can never outlive them and catch a card typed long after. */ +export function retireQueuePauseIfNothingHeld( + db: Database.Database, + input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } +): number { + // Runs on every appended journal row: with no pause recorded there is nothing to judge. + const recorded = db + .prepare('SELECT 1 FROM queued_message_pauses WHERE session_id = ?') + .get(input.sessionId) + if (recorded === undefined || queuePauseHoldsBack(db, input)) { + return 0 + } + return Number( + db.prepare('DELETE FROM queued_message_pauses WHERE session_id = ?').run(input.sessionId) + .changes ?? 0 + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-retention.ts b/src/main/native-chat/agent-session-journal/queued-message-retention.ts new file mode 100644 index 00000000000..cf18a1e5006 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-retention.ts @@ -0,0 +1,74 @@ +// Retention for the draft table: which settled rows may be deleted, and when. A row is kept for +// as long as anything could still read it — a replayed operation, or a late refusal returning it. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { listQueuedMessages } from './queued-message-table' + +/** What the loaded journal says about a dispatched draft's consumed submission. */ +export type QueuedMessageSubmissionVerdict = + /** Still owed an answer — a crash leftover the delivery loop will reject; keep the row. */ + | 'pending' + /** `accepted` or `unknown`: terminal and not refused. */ + | 'terminal-not-refused' + /** Absent from the current epoch. */ + | 'absent' + /** Effectively rejected; the open-time repair settles it rather than pruning. */ + | 'rejected' + +/** + * Retention: `withdrawn` tombstones live for the operation-replay window; + * a `dispatched` row only once its consumed submission is terminal-and-not- + * refused or absent AND the window has passed — never while pending, so a slow + * refusal can still return it. `waiting` and `returned` rows are never pruned. + */ +export function pruneQueuedMessages( + db: Database.Database, + input: { + sessionId: string + now: number + replayWindowMs: number + submissionVerdict: (consumedRef: string) => QueuedMessageSubmissionVerdict + } +): number { + const cutoff = input.now - input.replayWindowMs + const tombstones = db + .prepare( + `DELETE FROM queued_messages + WHERE session_id = ? AND state = 'withdrawn' AND settled_at IS NOT NULL AND settled_at < ?` + ) + .run(input.sessionId, cutoff) + let pruned = Number(tombstones.changes ?? 0) + for (const row of listQueuedMessages(db, input.sessionId)) { + if (row.state !== 'dispatched' || row.settledAt === null || row.settledAt >= cutoff) { + continue + } + // A dispatched row always names its hand-off; one that does not has nothing to wait for. + const verdict = row.consumedAs === null ? 'absent' : input.submissionVerdict(row.consumedAs) + if (verdict === 'terminal-not-refused' || verdict === 'absent') { + db.prepare('DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?').run( + input.sessionId, + row.messageId + ) + pruned += 1 + } + } + return pruned +} + +/** How retention reads a consumed submission from the loaded journal. Run after the + * owed settlements, which already settled every rejected row. */ +export function retainedSubmissionVerdict( + submissions: ReadonlyMap +): (consumedRef: string) => QueuedMessageSubmissionVerdict { + return (consumedRef) => { + const submission = submissions.get(consumedRef) + if (!submission) { + return 'absent' + } + if (submission.dispatchState === 'accepted' || submission.dispatchState === 'unknown') { + return 'terminal-not-refused' + } + return submission.dispatchState === 'rejected' ? 'rejected' : 'pending' + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts new file mode 100644 index 00000000000..31806e6b71a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -0,0 +1,72 @@ +// The draft table's shape, created and healed at every writable open. + +import type Database from '../../sqlite/sync-database' + +/** Columns a later build added, so an older draft table can gain them in place. */ +const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ + ['hold_reason', 'TEXT'], + ['returned_reason', 'TEXT'], + ['returned_rejection', 'TEXT'], + ['settled_at', 'INTEGER'], + ['settled_by_op', 'TEXT'], + ['consumed_as', 'TEXT'] +] + +/** + * Created idempotently at EVERY writable open, never lazily at first insert, so + * no reader hits "no such table". Deliberately no `user_version` bump: an old + * build sees stored == supported and stays writable, ignoring the table; a bump + * would latch every opened db read-only after a downgrade (`journal-database.ts`). + * For the same reason a missing column is added here rather than versioned: + * `CREATE TABLE IF NOT EXISTS` never reshapes a table an earlier build created. + */ +export function ensureQueuedMessagesTable(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS queued_messages ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + position INTEGER NOT NULL, + body_json TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, + state TEXT NOT NULL, + hold_reason TEXT, + returned_reason TEXT, + returned_rejection TEXT, + settled_at INTEGER, + settled_by_op TEXT, + consumed_as TEXT, + PRIMARY KEY (session_id, message_id) +); +`) + const names = new Set( + db + .prepare('PRAGMA table_info(queued_messages)') + .all() + .flatMap((column) => + typeof column === 'object' && + column !== null && + 'name' in column && + typeof column.name === 'string' + ? [column.name] + : [] + ) + ) + for (const [name, type] of NULLABLE_COLUMNS) { + if (!names.has(name)) { + db.exec(`ALTER TABLE queued_messages ADD COLUMN ${name} ${type}`) + } + } + db.exec(` +CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as + ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; +CREATE TABLE IF NOT EXISTS queued_message_pauses ( + session_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + epoch TEXT NOT NULL, + sequence INTEGER NOT NULL, + recorded_at INTEGER NOT NULL +); +`) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts new file mode 100644 index 00000000000..75e62256780 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -0,0 +1,142 @@ +// What a journal row does to the drafts, and the re-derivation behind it. The +// live hook runs inside each append's transaction; it is bookkeeping and may be +// skipped, so a dispatched draft whose current submission the journal already +// rejected is owed the settlement it would have applied, which the open-time +// repair and the drain both apply. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { + consumedSubmissionWasRejected, + journalDispatchRowNewlyRejects, + rejectedDraftSettlement +} from './journal-dispatch-settlement' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { + listQueuedMessages, + settleRejectedQueuedMessage, + withdrawQueuedMessages, + type QueuedMessageRow +} from './queued-message-table' + +type Submissions = ReadonlyMap + +/** Some dispatched draft still waits on a settlement the journal already decided. */ +export function queuedMessageSettlementOwed( + rows: readonly QueuedMessageRow[], + submissions: Submissions +): boolean { + return rows.some( + (row) => + row.state === 'dispatched' && + row.consumedAs !== null && + consumedSubmissionWasRejected(submissions.get(row.consumedAs)) + ) +} + +/** A dispatched draft's consumed submission settled so that the draft is owed a + * return to waiting (a withdrawal, not a refusal): what a queue pause must still + * count as a card it holds back while that settlement is owed. */ +export function owedBackToWaiting(submissions: Submissions): (consumedRef: string) => boolean { + return (consumedRef) => { + const submission = submissions.get(consumedRef) + return ( + submission !== undefined && + consumedSubmissionWasRejected(submission) && + rejectedDraftSettlement(submission).state === 'waiting' + ) + } +} + +/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves + * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ +export function settleOwedQueuedMessages( + db: Database.Database, + input: { sessionId: string; state: JournalReducerState; now: number } +): number { + const { submissions } = input.state + let settled = 0 + for (const row of listQueuedMessages(db, input.sessionId)) { + const consumedRef = row.consumedAs + const submission = consumedRef === null ? undefined : submissions.get(consumedRef) + if ( + row.state !== 'dispatched' || + consumedRef === null || + !consumedSubmissionWasRejected(submission) + ) { + continue + } + const changed = settleRejectedQueuedMessage(db, { + sessionId: input.sessionId, + consumedRef, + reason: submission?.reason ?? null, + rejection: submission?.rejection, + now: input.now + }) + settled += changed ? 1 : 0 + } + const delivered = draftsDeliveredByAppliedEcho( + input.state, + listQueuedMessages(db, input.sessionId) + ) + if (delivered.length > 0) { + settled += withdrawQueuedMessages(db, { + sessionId: input.sessionId, + messageIds: delivered, + settledByOp: null, + now: input.now + }).length + } + return settled +} + +/** + * The live hook, before `row` applies: an echo proving a waiting draft's first + * send was delivered withdraws it; a row that NEWLY settles a dispatched + * draft's current submission to `rejected` settles the draft — a refusal + * returns it, a withdrawal (a Stop, a restart) sends it back to waiting. + * Decided by the same function the reducer folds rows through, so a row the + * journal's settlement rules ignore never alters a draft. Returns how many + * drafts changed. + */ +export function settleQueuedMessagesForRow( + db: Database.Database, + input: { + sessionId: string + state: JournalReducerState + /** Read only once the row holds an unclaimed echo: a list read inside the append's + * transaction must not be cached under state a rollback could undo. */ + drafts: () => readonly QueuedMessageRow[] + row: JournalRow + now: number + } +): number { + const { row } = input + let changed = 0 + const delivered = draftDeliveredByEcho(input.state, input.drafts, row) + if (delivered !== null) { + // Its first send reached the agent after all; sending it again would repeat it. + changed += withdrawQueuedMessages(db, { + sessionId: input.sessionId, + messageIds: [delivered], + settledByOp: null, + now: input.now + }).length + } + if (row.kind !== 'dispatch' || row.state !== 'rejected') { + return changed + } + if (!journalDispatchRowNewlyRejects(input.state.submissions.get(row.clientMessageId), row)) { + return changed + } + const settled = settleRejectedQueuedMessage(db, { + sessionId: input.sessionId, + consumedRef: row.clientMessageId, + reason: row.reason, + rejection: row.rejection, + now: input.now + }) + return changed + (settled ? 1 : 0) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts new file mode 100644 index 00000000000..0e42a22be4d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -0,0 +1,841 @@ +// The draft store's contract: exactly-once consume in one transaction, the +// rejected-draft settlement following the journal's EFFECTIVE settlement, retention +// that never outruns a slow refusal, and rows that survive epoch replacement. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' +import { + JournalQueuedMessages, + QUEUED_MESSAGE_REPLAY_WINDOW_MS, + QueuedMessageNotConsumableError +} from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +const journals = createTrackedJournalOpener() +const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' +}) +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } +}) +/** A provider refusal as a settled rejection stores it: its sentence and typed fact. */ +function refusal(text: string) { + return agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }), + { surface: 'rejection' } + ) +} + +async function open(): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + return journal +} + +async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { + return journal.queuedMessages.insert({ + messageId, + body: message(text), + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) +} + +async function consumeDraft( + journal: AgentSessionJournal, + messageId: string, + options: { as?: string; expect?: 'waiting' | 'returned'; settledByOp?: string | null } = {} +) { + const draft = journal.queuedMessages.get(messageId) + await journal.appendSubmission( + { + clientMessageId: options.as ?? `sub-${messageId}`, + payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, + body: draft?.body ?? message('queued text'), + fence: 0, + handoverRecorded: true + }, + { + messageId, + expect: options.expect ?? 'waiting', + settledByOp: options.settledByOp ?? null + } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-message-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('draft rows', () => { + it('creates the table at open without bumping user_version, so an old build stays writable', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.close() + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + const version = Number(db.pragma('user_version', { simple: true })) + // An old build compares stored == supported and keeps writing; a bump + // would latch it read-only after downgrade. + expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('queued_messages') + expect(table).toBeDefined() + } finally { + db.close() + } + }) + + it('opens a database an older build shaped (no drafts table) and creates the table', async () => { + const first = await open() + await first.appendItem( + { provider: 'orca', clientMessageId: 'seed' }, + { kind: 'status', text: 'seed' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await first.close() + // The table is created when the host opens its database, so the host restarts around the edit. + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.exec('DROP TABLE queued_messages') + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + const row = await queueDraft(journal, 'draft-1') + expect(row.position).toBe(1) + }) + + it('assigns monotonic positions and lists in order', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await queueDraft(journal, 'draft-2') + const listed = journal.queuedMessages.list() + expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([ + ['draft-1', 1, 'waiting'], + ['draft-2', 2, 'waiting'] + ]) + }) + + it('replays an insert under an already-used id instead of duplicating', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const again = await queueDraft(journal, 'draft-1') + expect(again.position).toBe(1) + expect(journal.queuedMessages.list()).toHaveLength(1) + }) + + it('drafts survive epoch replacement, which deletes only journal rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1']) + }) +}) + +describe('consume', () => { + it('converts waiting → dispatched and appends the submission in one transaction', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('dispatched') + expect(row?.consumedAs).toBe('sub-draft-1') + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('never records a second submission under an id it already holds, whatever state it settled in', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const cursor = journal.cursor() + await expect( + journal.appendSubmission({ + clientMessageId: 'sub-draft-1', + payloadFingerprint: 'fp-draft-1', + body: message('queued text'), + fence: 0, + handoverRecorded: true + }) + ).rejects.toMatchObject({ code: 'journal_submission_exists' }) + // The refusal stands: re-appending would reset it to pending and hand it over again. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.cursor()).toEqual(cursor) + }) + + it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('a consume racing a withdraw loses and appends nothing', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000op-1' + }) + await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('a failed submission insert rolls the draft transition back', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const cursor = journal.cursor() + // Occupy the next sequence directly so the append's INSERT violates the + // primary key inside the transaction, after the draft was transitioned. + const db = new Database(journalDatabasePath(root)) + db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') + db.close() + await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) +}) + +describe('returned transition (D1/N4)', () => { + it('a non-withdrawn rejection returns the consumed draft with its reason', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('Claude refused this payload'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('a late rejection row after acceptance settles nothing and returns no card', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('late duplicate'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + // The Stop's own withdrawal path: the queued (not handed over) submission. + expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1']) + // Nothing failed: no refusal to show, its position kept, its spent id recorded. + const requeued = { + state: 'waiting', + position: 1, + holdReason: null, + consumedAs: null, + returnedReason: null, + returnedRejection: null + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + // Atomic with the rejection row: a crash before the Stop answered keeps the text. + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + }) + + it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + // The spent id already names a rejected submission: one id, one delivery. + await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({ + code: 'journal_submission_exists' + }) + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + await consumeDraft(journal, 'draft-1', { as: 'fresh-1' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'fresh-1' + }) + // Both hand-offs name the draft. + expect(journal.submission('fresh-1')).toMatchObject({ + dispatchState: 'pending', + queuedMessageId: 'draft-1' + }) + expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') + }) + + it("a restart between consume and handover sends the draft back to waiting under the restart's pause", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + // No stored hold: the restart's pause derives from the row's host instance. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + hostInstance: 'proc-1', + consumedAs: null, + returnedReason: null + }) + }) + + it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('first refusal'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned') + // Send on the returned card re-consumes under a fresh submission id. + await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' }) + // The earlier refusal retires with the card: the row now describes the re-send. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'resend-1', + returnedReason: null, + returnedRejection: null + }) + // A duplicate resolution of the FIRST submission is ignored by the journal + // and must not alter the draft's current relation. + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('duplicate of first refusal'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The re-send's own refusal returns the card, matched via consumed_as. + await journal.resolveDispatch({ + clientMessageId: 'resend-1', + state: 'rejected', + ...refusal('second refusal'), + fence: 0 + }) + const returned = journal.queuedMessages.get('draft-1') + expect(returned?.state).toBe('returned') + expect(returned?.returnedReason).toBe(refusal('second refusal').reason) + // The first refusal's fact does not outlive it: the pair is the second submission's. + expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection) + expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL) + }) + + it('a rejection never revives a withdrawn draft', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('again'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('stored refusal'), + fence: 0 + }) + await journal.replaceEpochItems('handle_forked', 0, []) + await journal.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('stored refusal').reason) + }) +}) + +describe('withdraw', () => { + it('withdraws waiting and returned rows together into op-stamped receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1', 'first text') + await queueDraft(journal, 'draft-2', 'second text') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const withdrawn = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1', 'draft-2'], + settledByOp: 'caller\u0000stop-1' + }) + expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([ + ['draft-1', [{ type: 'text', text: 'first text' }]], + ['draft-2', [{ type: 'text', text: 'second text' }]] + ]) + // A lost acknowledgement replays from the tombstones, keyed by the + // caller-scoped operation key — never from the ledger. + const receipts = journal.queuedMessages.receipts('caller\u0000stop-1') + expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2']) + // Pending or dispatched rows stay outside the withdrawable set. + const second = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000stop-2' + }) + expect(second).toHaveLength(0) + }) + + it('two callers reusing one operation id read only their own receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller-a\u0000op-1' + }) + expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0) + expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1) + }) +}) + +describe('open-time repair and retention', () => { + it('a failed repair is reported and skipped, never failing the open', async () => { + const repair = vi + .spyOn(JournalQueuedMessages.prototype, 'repairAndPrune') + .mockRejectedValueOnce(new Error('SQLITE_FULL')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const journal = await open() + expect(warn).toHaveBeenCalledWith( + '[journal-open] queued-message repair skipped:', + expect.objectContaining({ error: 'SQLITE_FULL' }) + ) + await queueDraft(journal, 'draft-1') + expect(journal.queuedMessages.list()).toHaveLength(1) + } finally { + repair.mockRestore() + warn.mockRestore() + } + }) + + it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused while downgraded'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + await journal.close() + // Simulate the old build having written the rejection with no hook: put the + // draft back to dispatched behind the stored fact. + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + await journal.close() + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + // The same settlement the live hook applies. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + consumedAs: null, + returnedReason: null + }) + }) + + it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + // Reopen "25 hours" later: the submission is still queued/pending. + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The delivery loop's leftover rejection now sends it back to waiting. + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) + }) + + it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => { + let journal = await open() + await queueDraft(journal, 'accepted-1') + await consumeDraft(journal, 'accepted-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-accepted-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await queueDraft(journal, 'waiting-1') + await queueDraft(journal, 'returned-1') + await consumeDraft(journal, 'returned-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-returned-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([ + ['waiting-1', 'waiting'], + ['returned-1', 'returned'] + ]) + }) + + it('keeps fresh tombstones inside the replay window', async () => { + let journal = await open() + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await journal.close() + clock += 1_000 + journal = await open() + expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn') + }) +}) + +describe('holds', () => { + it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + holdReason: null + }) + }) + + it('consume clears the hold in the same transaction (Send-now overrides it)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + await consumeDraft(journal, 'draft-1') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + holdReason: null + }) + }) + + it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => { + const journal = await open() + await journal.close() + await expect( + journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' }) + ).resolves.toEqual([]) + }) + + it('holds reach only waiting rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + holdReason: null + }) + }) +}) + +describe("the queue's Stop fact", () => { + it('records where the Stop took effect, survives reopen, and the latest Stop replaces an earlier one', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('stopped') + const first = journal.queuedMessages.pause() + expect(first).toMatchObject({ reason: 'stopped', sequence: journal.cursor().sequence }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'later' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.queuedMessages.recordPause('stopped') + expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) + // The pause is the queue's, never a row's. + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBeNull() + }) + + it("a /clear's replacement records its pause as 'cleared', read back the same way", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('cleared') + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'cleared' }) + }) + + it('retires in the write that takes the last card it holds back: a hold of its own', async () => { + const journal = await open() + await queueDraft(journal, 'draft-held') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await journal.queuedMessages.hold({ messageIds: ['draft-held'], reason: 'send_failed' }) + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it('records nothing over a queue with no card it holds back, judged in its own transaction', async () => { + const journal = await open() + expect(await journal.queuedMessages.recordPause('stopped')).toBe(false) + expect(journal.queuedMessages.pause()).toBeNull() + await queueDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + + it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { + const journal = await open() + await queueDraft(journal, 'draft-sent') + await queueDraft(journal, 'draft-other') + await consumeDraft(journal, 'draft-sent') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-sent')?.state).toBe('dispatched') + // The only waiting card goes; the owed one is still a card the pause holds back. + await journal.queuedMessages.withdraw({ messageIds: ['draft-other'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.pause()).not.toBeNull() + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-sent')?.state).toBe('waiting') + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + + it('lifting retires only the Stop fact it judged, never one recorded since', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('stopped') + const judged = journal.queuedMessages.pause() + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'later' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.queuedMessages.recordPause('stopped') + expect(await journal.queuedMessages.liftPause({ stop: judged, adoptInto: null })).toBe(false) + expect(journal.queuedMessages.pause()).not.toBeNull() + expect( + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: null + }) + ).toBe(true) + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-restart', + body: message('written before the restart'), + fingerprint: 'fp-draft-restart', + hostInstance: 'proc-0' + }) + await queueDraft(journal, 'draft-legacy') + await queueDraft(journal, 'draft-failed') + await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( + 'draft-legacy' + ) + db.close() + journal.queuedMessages.invalidate() + expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(true) + expect( + journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) + ).toEqual([ + ['draft-restart', 'proc-1', null], + ['draft-legacy', 'proc-1', null], + ['draft-failed', 'proc-1', 'send_failed'] + ]) + }) + + it('a lift with nothing to lift changes nothing and fires no commit notification', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const revision = journal.queuedMessages.revision() + expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(false) + expect(journal.queuedMessages.revision()).toBe(revision) + }) +}) + +describe('the commit listener', () => { + it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { + const journal = await open() + let commits = 0 + journal.observeCommits(() => { + commits += 1 + }) + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + // An idempotent replay changes nothing and stays silent. + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(commits).toBe(3) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' }) + expect(commits).toBe(3) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts new file mode 100644 index 00000000000..ffc9b240987 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -0,0 +1,326 @@ +// Host-owned draft rows for messages queued while the main agent is working. +// +// A queued message is NOT a journal row: it becomes one — an ordinary +// submission — only when consume converts it, in the same transaction as the +// submission's append. Until then it lives here, `session_id`-keyed so it +// survives epoch rollover and replacement (`journal-row-table.ts` deletes only +// `journal_rows`). After a refusal its text survives as a `returned` row a +// rewind cannot delete; after a withdrawal it waits again. + +import type Database from '../../sqlite/sync-database' +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { rejectedDraftSettlement } from './journal-dispatch-settlement' +import { readStoredRejectionFact } from './journal-dispatch-reducer' + +export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' + +/** Why ONE waiting draft is held from auto-sending: its conversion failed. + * Stored on the row, so it survives handle eviction and restart; a wire marker + * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue + * instead. A reader treats an unknown stored value as a plain hold. */ +export type QueuedMessageHoldReason = 'send_failed' + +/** Definitively unsettled: what Stop, /clear, Edit and the budget count, and + * what the published list shows. Pending/unknown/accepted deliveries and + * tombstones stay outside it. */ +export function isUnsettledQueuedMessage(row: Pick): boolean { + return row.state === 'waiting' || row.state === 'returned' +} + +export type QueuedMessageRow = { + sessionId: string + messageId: string + position: number + body: AgentJournalMessageItem + fingerprint: string + createdAt: number + hostInstance: string + state: QueuedMessageState + /** Non-null holds this one waiting draft from auto-sending; typed values in + * `QueuedMessageHoldReason`, unknown strings read as a plain hold. */ + holdReason: string | null + /** A returned card's refusal, mirroring its submission's `reason` and `rejection` pair. */ + returnedReason: string | null + returnedRejection: UnreadAgentSessionFailureFact | null + settledAt: number | null + /** The operation ledger's caller-scoped key, making settled rows mutation receipts. */ + settledByOp: string | null + /** The submission that last handed it off: set on every dispatched row, kept on a returned + * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is + * the submission's `queuedMessageId`. */ + consumedAs: string | null +} + +const COLUMNS = + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as' + +export function insertQueuedMessage( + db: Database.Database, + input: { + sessionId: string + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + now: number + } +): QueuedMessageRow { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. + const highest = db + .prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?') + .get(input.sessionId) as { p?: number } | undefined + const position = Number(highest?.p ?? 0) + 1 + db.prepare( + `INSERT INTO queued_messages (${COLUMNS}) + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL)` + ).run( + input.sessionId, + input.messageId, + position, + JSON.stringify(input.body), + input.fingerprint, + input.now, + input.hostInstance + ) + return { + sessionId: input.sessionId, + messageId: input.messageId, + position, + body: input.body, + fingerprint: input.fingerprint, + createdAt: input.now, + hostInstance: input.hostInstance, + state: 'waiting', + holdReason: null, + returnedReason: null, + returnedRejection: null, + settledAt: null, + settledByOp: null, + consumedAs: null + } +} + +export function listQueuedMessages(db: Database.Database, sessionId: string): QueuedMessageRow[] { + return db + .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`) + .all(sessionId) + .flatMap((row) => toStoredRow(row) ?? []) +} + +export function getQueuedMessage( + db: Database.Database, + sessionId: string, + messageId: string +): QueuedMessageRow | null { + const row = db + .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`) + .get(sessionId, messageId) + return row === undefined ? null : toStoredRow(row) +} + +/** + * The one waiting→dispatched (or returned→dispatched) transition, always under + * a fresh submission id — never the draft's own — so no reader can mistake id + * equality for the hand-off link. MUST run inside the caller's transaction — the journal + * writer's, between BEGIN IMMEDIATE and COMMIT — so a failed submission append + * rolls the consume back and a failed consume rolls the append back. Returns + * false when the draft was not in the expected state, in which case the caller + * throws to abort the append. + */ +export function consumeQueuedMessageInTransaction( + db: Database.Database, + input: { + sessionId: string + messageId: string + expect: 'waiting' | 'returned' + /** The fresh submission id; never the draft's own id. */ + consumedAs: string + settledByOp: string | null + /** The handing-off process; absent keeps the row's own. */ + hostInstance?: string + now: number + } +): boolean { + if (input.consumedAs === input.messageId) { + return false + } + const changed = db + .prepare( + `UPDATE queued_messages + SET state = 'dispatched', hold_reason = NULL, returned_reason = NULL, returned_rejection = NULL, + settled_at = ?, settled_by_op = ?, consumed_as = ?, host_instance = COALESCE(?, host_instance) + WHERE session_id = ? AND message_id = ? AND state = ?` + ) + .run( + input.now, + input.settledByOp, + input.consumedAs, + input.hostInstance ?? null, + input.sessionId, + input.messageId, + input.expect + ) + return Number(changed.changes ?? 0) === 1 +} + +/** Compare-and-transition unsettled rows (waiting ∪ returned) to withdrawn + * tombstones stamped with the operation's caller-scoped key, kept only so a + * replay of the settling operation answers "spent"; null when the host itself + * withdrew it. Returns the rows actually transitioned; their text stays in + * this database, never on the wire. */ +export function withdrawQueuedMessages( + db: Database.Database, + input: { + sessionId: string + messageIds: readonly string[] + settledByOp: string | null + now: number + } +): QueuedMessageRow[] { + const withdrawn: QueuedMessageRow[] = [] + for (const messageId of input.messageIds) { + const row = getQueuedMessage(db, input.sessionId, messageId) + if (!row || !isUnsettledQueuedMessage(row)) { + continue + } + db.prepare( + `UPDATE queued_messages + SET state = 'withdrawn', hold_reason = NULL, settled_at = ?, settled_by_op = ? + WHERE session_id = ? AND message_id = ? AND state IN ('waiting', 'returned')` + ).run(input.now, input.settledByOp, input.sessionId, messageId) + withdrawn.push({ + ...row, + state: 'withdrawn', + holdReason: null, + settledAt: input.now, + settledByOp: input.settledByOp + }) + } + return withdrawn +} + +/** + * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), + * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused + * again still settles while a late duplicate of an earlier refusal matches + * nothing. A draft back to waiting keeps its position and carries no refusal; + * its spent submissions stay findable by their `queuedMessageId` link. + */ +export function settleRejectedQueuedMessage( + db: Database.Database, + input: { + sessionId: string + consumedRef: string + reason: string | null + rejection: UnreadAgentSessionFailureFact | undefined + now: number + } +): boolean { + const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection }) + const changed = + settlement.state === 'waiting' + ? db + .prepare( + `UPDATE queued_messages + SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, + returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + .run(input.sessionId, input.consumedRef) + : db + .prepare( + `UPDATE queued_messages + SET state = 'returned', returned_reason = ?, returned_rejection = ?, settled_at = ? + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + .run( + input.reason, + input.rejection ? JSON.stringify(input.rejection) : null, + input.now, + input.sessionId, + input.consumedRef + ) + return Number(changed.changes ?? 0) > 0 +} + +/** Replay receipts: every row a given caller-scoped operation settled. */ +export function queuedMessagesSettledByOp( + db: Database.Database, + sessionId: string, + settledByOp: string +): QueuedMessageRow[] { + return db + .prepare( + `SELECT ${COLUMNS} FROM queued_messages + WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC` + ) + .all(sessionId, settledByOp) + .flatMap((row) => toStoredRow(row) ?? []) +} + +function toStoredRow(row: unknown): QueuedMessageRow | null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. + const record = row as { + session_id: string + message_id: string + position: number + body_json: string + fingerprint: string + created_at: number + host_instance: string + state: string + hold_reason: string | null + returned_reason: string | null + returned_rejection: string | null + settled_at: number | null + settled_by_op: string | null + consumed_as: string | null + } + let body: AgentJournalMessageItem + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. + body = JSON.parse(record.body_json) as AgentJournalMessageItem + } catch { + // Our own writer stringified it; an unreadable body is corruption, and a + // row we cannot re-materialize must not masquerade as an empty message. + return null + } + const state = record.state + if ( + state !== 'waiting' && + state !== 'dispatched' && + state !== 'returned' && + state !== 'withdrawn' + ) { + return null + } + return { + sessionId: record.session_id, + messageId: record.message_id, + position: record.position, + body, + fingerprint: record.fingerprint, + createdAt: record.created_at, + hostInstance: record.host_instance, + state, + holdReason: record.hold_reason, + returnedReason: record.returned_reason, + returnedRejection: storedRejection(record.returned_rejection), + settledAt: record.settled_at, + settledByOp: record.settled_by_op, + consumedAs: record.consumed_as + } +} + +function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { + if (json === null) { + return null + } + try { + return readStoredRejectionFact(JSON.parse(json)) ?? null + } catch { + // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. + return null + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts index 5a859fb2e81..1ab1089d494 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createCodexStructuredItemStreams } from '../../codex/codex-structured-item-streams' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' @@ -9,7 +10,7 @@ describe('empty streamed deltas', () => { sink: { appendItem() {}, appendTombstone() {}, publish() {} }, turnIdFor: () => 'turn', identityFor: () => ({ provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 }), - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: () => () => {} }) const append = (delta: string) => diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 17bdf710490..7711b670438 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,7 +8,7 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' @@ -40,7 +41,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number, text: string): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -50,7 +54,7 @@ beforeEach(async () => { epochs = 0 journal = await journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => { epochs += 1 diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts new file mode 100644 index 00000000000..8438b40d122 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryRequest +} from '../../../shared/agent-session-wire' +import { serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from './agent-session-history-page' + +// A backward page windows over the session's own rows, and a subagent's rows in +// that range ride along, so a burst cannot crowd the conversation off the page. + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } +const journals = createTrackedJournalOpener() +let root: string +let clock = 1_000 +let ordinal = 0 +let journal: AgentSessionJournal + +function said(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function appendRoster(agentIds: string[]): Promise { + ordinal += 1 + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }, + { + kind: 'message', + role: 'system', + blocks: [ + { + type: 'subagent-group', + groupId: `group-${ordinal}`, + agents: agentIds.map((id) => ({ id, label: `run ${id}`, state: 'working' as const })) + } + ] + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +async function append(texts: string[], linkage: AgentJournalProducerLinkage = {}): Promise { + for (const text of texts) { + ordinal += 1 + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }, + said(text), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...linkage } + ) + } +} + +const named = (prefix: string, from: number, count: number): string[] => + Array.from({ length: count }, (_, index) => `${prefix}${from + index}`) + +function textsOf(page: AgentSessionHistoryPage): string[] { + return page.items.map((entry) => + entry.body.kind === 'message' && entry.body.blocks[0]?.type === 'text' + ? entry.body.blocks[0].text + : '' + ) +} + +function read(request: Omit): AgentSessionHistoryPage { + const result = readAgentSessionHistory(journal, { sessionId: 'session-1', ...request }) + if (!result.ok) { + throw new Error(`expected a page, got reset ${result.reset}`) + } + serializeRemoteRuntimePayload(result.page) + return result.page +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-history-window-')) + clock = 1_000 + ordinal = 0 + journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => 'epoch-1' + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a history page over a session with a subagent', () => { + it("counts the session's own rows and carries the subagent's rows between them", async () => { + await append(named('own-', 1, 5)) + await append(named('child-', 1, 300), child) + await append(['own-6']) + + const tail = read({ direction: 'tail', limit: 3 }) + expect(textsOf(tail)).toEqual(['own-4', 'own-5', ...named('child-', 1, 300), 'own-6']) + expect(tail.hasOlder).toBe(true) + + const older = read({ direction: 'before', cursor: tail.window.nextCursor, limit: 3 }) + expect(textsOf(older)).toEqual(['own-1', 'own-2', 'own-3']) + expect(older.hasOlder).toBe(false) + expect(older.hasNewer).toBe(true) + }) + + it('opens on the conversation, not a page of the burst that followed it', async () => { + await append(named('own-', 1, 5)) + await append(named('child-', 1, 300), child) + + const page = readAgentSessionHydrationPage(journal) + + expect(textsOf(page)).toEqual([...named('own-', 1, 5), ...named('child-', 1, 300)]) + expect(page.hasOlder).toBe(false) + }) + + it('reaches back to the start, not a page of the subagent alone, once fewer own rows remain', async () => { + await append(named('child-', 1, 3), child) + await append(named('own-', 1, 2)) + + const tail = read({ direction: 'tail', limit: 5 }) + + expect(textsOf(tail)).toEqual([...named('child-', 1, 3), 'own-1', 'own-2']) + expect(tail.hasOlder).toBe(false) + }) + + it('still bounds a page by bytes, and paging back still reaches every row', async () => { + const large = 'x'.repeat(250 * 1024) + await append(['own-1']) + await append(named(`${large}:`, 1, 20), child) + await append(['own-2']) + + const tail = read({ direction: 'tail', limit: 40 }) + expect(tail.hasOlder).toBe(true) + const seen = [...tail.items] + let page = tail + for (let guard = 0; page.hasOlder; guard += 1) { + expect(guard).toBeLessThan(30) + page = read({ direction: 'before', cursor: page.window.nextCursor, limit: 40 }) + expect(page.items.length).toBeGreaterThan(0) + seen.unshift(...page.items) + } + const sequences = seen.map((entry) => entry.sequence) + expect(sequences).toHaveLength(22) + expect(sequences).toEqual([...sequences].sort((a, b) => a - b)) + expect(new Set(sequences).size).toBe(22) + }) +}) + +describe('a history page names the subagents whose roster row is older than it', () => { + it('names a subagent whose roster row is just above the opening page', async () => { + await append(['own-1']) + await appendRoster(['task-1']) + await append(['child-1'], child) + await append(named('own-', 2, 200)) + await append(['child-2'], child) + + const page = readAgentSessionHydrationPage(journal) + + expect(textsOf(page)[0]).toBe('own-2') + expect(page.subagentRoster?.map((named) => [named.entry.id, named.entry.label])).toEqual([ + ['task-1', 'run task-1'] + ]) + }) + + it('names nothing beside the items when the page carries the roster row', async () => { + await append(['own-1']) + await appendRoster(['task-1']) + await append(['child-1'], child) + + expect(readAgentSessionHydrationPage(journal).subagentRoster).toBeUndefined() + expect(read({ direction: 'tail', limit: 5 }).subagentRoster).toBeUndefined() + }) + + it('names a bounded number of subagents', async () => { + const agents = named('task-', 1, 70) + await appendRoster(agents) + await append(['own-1']) + for (const agentId of agents) { + await append([`${agentId} says`], { agentId, producerKind: 'agent' }) + } + await append(['own-2']) + + const tail = read({ direction: 'tail', limit: 2 }) + + expect(textsOf(tail)[0]).toBe('own-1') + expect(tail.subagentRoster).toHaveLength(64) + }) +}) + +describe('a history page over a session with no subagent rows', () => { + it('serves exactly the newest rows per page, as before', async () => { + await append(named('own-', 1, 23)) + + const pages: string[][] = [] + let page = read({ direction: 'tail', limit: 5 }) + pages.push(textsOf(page)) + while (page.hasOlder) { + page = read({ direction: 'before', cursor: page.window.nextCursor, limit: 5 }) + pages.push(textsOf(page)) + } + + expect(pages).toEqual([ + named('own-', 19, 5), + named('own-', 14, 5), + named('own-', 9, 5), + named('own-', 4, 5), + named('own-', 1, 3) + ]) + }) + + it('opens on the newest 200 rows, as before', async () => { + await append(named('own-', 1, 250)) + + const page = readAgentSessionHydrationPage(journal) + + expect(textsOf(page)).toEqual(named('own-', 51, 200)) + expect(page.hasOlder).toBe(true) + expect(page).not.toHaveProperty('subagentRoster') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 5a9a6f20801..8abfce258b8 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -8,15 +8,16 @@ import { type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { - insertJournalRow, - upsertJournalSessionRow -} from '../agent-session-journal/journal-row-table' import * as journalReducer from '../agent-session-journal/journal-reducer' import * as rowSchema from '../agent-session-journal/journal-row-schema' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow, + publishTestJournalEpoch, + deleteTestJournalRow, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { readAgentSessionHistory } from './agent-session-history-page' @@ -40,42 +41,38 @@ afterEach(async () => { async function seedJournal(count: number) { root = await mkdtemp(join(tmpdir(), 'orca-history-read-budget-')) - const { db } = openJournalDatabase(journalDatabaseFile(root)) + const { db } = openTestJournalHostDatabase(root) const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1_000 } - try { - db.exec('BEGIN IMMEDIATE') - upsertJournalSessionRow(db, identity.sessionId, base.epoch, base.ts) - insertJournalRow(db, identity.sessionId, { + db.exec('BEGIN IMMEDIATE') + publishTestJournalEpoch(db, identity.sessionId, base.epoch) + insertTestJournalRow(db, identity.sessionId, { + ...base, + kind: 'epoch', + seq: 1, + reason: 'session_created', + providerHandle: identity.providerHandle + }) + for (let index = 0; index < count; index += 1) { + insertTestJournalRow(db, identity.sessionId, { ...base, - kind: 'epoch', - seq: 1, - reason: 'session_created', - providerHandle: identity.providerHandle + kind: 'item', + seq: index + 2, + itemId: `item-${index}`, + revision: 1, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `${index}:${'x'.repeat(4096)}` }] + } }) - for (let index = 0; index < count; index += 1) { - insertJournalRow(db, identity.sessionId, { - ...base, - kind: 'item', - seq: index + 2, - itemId: `item-${index}`, - revision: 1, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `${index}:${'x'.repeat(4096)}` }] - } - }) - } - db.exec('COMMIT') - } finally { - db.close() } - return journals.open({ identity, journalDir: root }) + db.exec('COMMIT') + return journals.open({ identity, stateDirectory: root }) } function observeForwardReads() { @@ -166,15 +163,8 @@ describe('forward history SQL read budget', () => { it('reports a sequence gap when the next page reaches it', async () => { const journal = await seedJournal(6) - const { db } = openJournalDatabase(journalDatabaseFile(root!)) - try { - db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( - identity.sessionId, - 4 - ) - } finally { - db.close() - } + const { db } = openTestJournalHostDatabase(root!) + deleteTestJournalRow(db, identity.sessionId, 4) const first = readAgentSessionHistory(journal, { sessionId: identity.sessionId, direction: 'after', @@ -199,16 +189,8 @@ describe('forward history SQL read budget', () => { 'preserves parse-stop behavior at lookahead: %s', async (rowJson) => { const journal = await seedJournal(6) - const { db } = openJournalDatabase(journalDatabaseFile(root!)) - try { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?').run( - rowJson, - identity.sessionId, - 4 - ) - } finally { - db.close() - } + const { db } = openTestJournalHostDatabase(root!) + updateTestJournalRowJson(db, identity.sessionId, 4, rowJson) const page = readAgentSessionHistory(journal, { sessionId: identity.sessionId, direction: 'after', diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index cda878f5a01..ea6f44edb28 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -12,17 +12,21 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow +} from '../agent-session-journal/journal-host-database-test-support' import type { JournalItemRow, JournalRow, @@ -61,7 +65,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -71,7 +78,7 @@ beforeEach(async () => { epochs = 0 journal = await journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => { epochs += 1 @@ -216,7 +223,10 @@ describe('history page byte ceiling', () => { async function appendLargeItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -300,7 +310,10 @@ describe('history page byte ceiling', () => { }) it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { - await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1 }) + await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const tail = pageOf( readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) @@ -320,14 +333,28 @@ describe('projectJournalBatch', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const cursor = journal.cursor() await journal.appendLifecycleBatch({ settlementId: 'settlement-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('one') }, - { kind: 'item', identity: item(2), body: body('two') }, + { + kind: 'item', + identity: item(1), + body: body('one'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: item(2), + body: body('two'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'tombstone', identity: turn } ] }) @@ -371,7 +398,10 @@ describe('projectJournalBatch', () => { it('publishes touched items at their current reduced state, not as a delta', async () => { await appendItems(1) const cursor = journal.cursor() - await journal.appendItem(item(1), body('revised'), { fence: 1 }) + await journal.appendItem(item(1), body('revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const since = journal.readSince(cursor) if (!since.ok) { throw new Error(`expected rows, got reset ${since.reset}`) @@ -440,7 +470,7 @@ describe('projectJournalBatch', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'root-turn', ordinal: 2 }, message, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const page = readAgentSessionHistory(journal, { @@ -483,17 +513,17 @@ async function reopenWithRawRows(rows: readonly RawSeedRow[]): Promise { @@ -636,7 +666,10 @@ describe('identity bounding at admission', () => { ordinal: 1 } const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } - const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + const appended = await journal.appendItem(oversized, body('bounded'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(appended.itemId.length).toBeLessThan(2048) expect(appended.itemId).toContain('~orca-oversized~') diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index f7bef47e553..1fd376e2aae 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -8,6 +8,7 @@ // `after` is the only direction that can answer `cursor_compacted`. import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import type { AgentJournalCursor, AgentJournalRenderItem, @@ -19,7 +20,8 @@ import { type AgentSessionHistoryDirection, type AgentSessionHistoryPage, type AgentSessionHistoryRequest, - type AgentSessionHistoryResult + type AgentSessionHistoryResult, + type AgentSessionSubagentRosterEntry } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { projectJournalBatch } from './agent-session-journal-batch' @@ -31,6 +33,7 @@ import { oversizedHistoryItem, submissionBytesByItemId } from './agent-session-history-page-bounds' +import { offPageSubagentRoster } from './agent-session-history-subagent-roster' export { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' @@ -43,12 +46,39 @@ export function resolveHistoryLimit(limit: number | undefined): number { return Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, Math.max(1, Math.floor(limit))) } +/** Whose rows a backward read serves. Both window over the session's own rows; a + * reader of the session's own agent alone gets only those. In-process only: no wire + * request carries it. */ +export type AgentSessionHistoryScope = 'every-agent' | 'own-agent' + +/** + * The newest `limit` of the session's own rows, in whole sequence groups, and every + * row after the oldest of them: a subagent's rows ride along with the conversation + * they happened in, so its burst cannot crowd the conversation off the page. The page + * stays contiguous and starts at its first item, as the cursor means. Fewer own rows + * than `limit` reach back to the start. With no subagent rows this is the newest + * `limit` rows, as before. + */ +function conversationWindow( + items: readonly AgentJournalRenderItem[], + limit: number +): AgentJournalRenderItem[] { + const own = items.filter(isRootAgentJournalItem) + if (own.length === items.length) { + return newestWholeSequenceGroups(items, limit) + } + const ownWindow = newestWholeSequenceGroups(own, limit) + const start = ownWindow.length < own.length ? ownWindow[0]?.sequence : undefined + return start === undefined ? [...items] : items.filter((item) => item.sequence >= start) +} + export function readAgentSessionHistory( journal: AgentSessionJournal, request: AgentSessionHistoryRequest, /** Reduced state to read against. A synchronous multi-page catch-up passes one * snapshot for the whole run so each page costs its own rows, not the timeline. */ - snapshot: AgentJournalSnapshot = journal.snapshot() + snapshot: AgentJournalSnapshot = journal.snapshot(), + scope: AgentSessionHistoryScope = 'every-agent' ): AgentSessionHistoryResult { if (journal.isReadOnly) { return historyReset(snapshot, 'schema_unreadable') @@ -66,10 +96,10 @@ export function readAgentSessionHistory( return historyReset(snapshot, 'cursor_ahead') } } - const older = cursor - ? snapshot.items.filter((item) => item.sequence < cursor.sequence) - : snapshot.items - const windowed = newestWholeSequenceGroups(older, limit) + const scoped = + scope === 'own-agent' ? snapshot.items.filter(isRootAgentJournalItem) : snapshot.items + const older = cursor ? scoped.filter((item) => item.sequence < cursor.sequence) : scoped + const windowed = conversationWindow(older, limit) const { items, dropped } = boundHistoryItemsByBytes( windowed, 'newest', @@ -83,11 +113,12 @@ export function readAgentSessionHistory( direction: request.direction, items, hasOlder: older.length > windowed.length || dropped > 0, - hasNewer: older.length < snapshot.items.length, + hasNewer: older.length < scoped.length, fallbackCursor: cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, nextCursor: items[0] ? { epoch: snapshot.cursor.epoch, sequence: items[0].sequence } - : undefined + : undefined, + subagentRoster: offPageSubagentRoster(scoped, items) }) } } @@ -121,7 +152,7 @@ function buildHydrationPage( snapshot: AgentJournalSnapshot, fence?: number ): AgentSessionHistoryPage { - const items = newestWholeSequenceGroups(snapshot.items, AGENT_SESSION_HISTORY_MAX_LIMIT) + const items = conversationWindow(snapshot.items, AGENT_SESSION_HISTORY_MAX_LIMIT) const bounded = boundHistoryItemsByBytes( items, 'newest', @@ -138,7 +169,8 @@ function buildHydrationPage( nextCursor: bounded.items[0] ? { epoch: snapshot.cursor.epoch, sequence: bounded.items[0].sequence } : undefined, - fence + fence, + subagentRoster: offPageSubagentRoster(snapshot.items, bounded.items) }) } @@ -255,6 +287,7 @@ function buildPage(input: { fallbackCursor: AgentJournalCursor nextCursor: AgentJournalCursor | undefined fence?: number + subagentRoster?: AgentSessionSubagentRosterEntry[] }): AgentSessionHistoryPage { const epoch = input.snapshot.cursor.epoch const pageItemIds = new Set(input.items.map((item) => item.itemId)) @@ -277,6 +310,7 @@ function buildPage(input: { }, liveCursor: input.snapshot.cursor, hasOlder: input.hasOlder, - hasNewer: input.hasNewer + hasNewer: input.hasNewer, + ...(input.subagentRoster === undefined ? {} : { subagentRoster: input.subagentRoster }) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts b/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts new file mode 100644 index 00000000000..adf24fb9444 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts @@ -0,0 +1,76 @@ +// The roster entries a history page needs but does not carry. +// +// A page serves a contiguous run of the journal, and its cursor is its first item, so it +// cannot pull an older roster row in without skipping the rows between. A subagent whose +// rows are on the page but whose spawn is older than it would then reach the client with +// no name. The page names it beside its items instead; items and cursor are unchanged. + +import { + agentJournalItemSubagentId, + isRootAgentJournalItem +} from '../../../shared/agent-session-journal-producer' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubagentRosterEntry } from '../../../shared/agent-session-wire' +import { + isSubagentGroupBlock, + type NativeChatSubagentEntry +} from '../../../shared/native-chat-types' + +/** Bounds the field well inside the page's envelope reserve. */ +const MAX_ENTRIES = 64 +const MAX_BYTES = 16 * 1024 + +function rosterEntries(item: AgentJournalRenderItem): readonly NativeChatSubagentEntry[] { + if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') { + return [] + } + return item.body.blocks.flatMap((block) => (isSubagentGroupBlock(block) ? block.agents : [])) +} + +/** `journal` in journal order; `page` a run of it. Undefined when the page names every + * subagent it holds. */ +export function offPageSubagentRoster( + journal: readonly AgentJournalRenderItem[], + page: readonly AgentJournalRenderItem[] +): AgentSessionSubagentRosterEntry[] | undefined { + const unnamed = new Set() + for (const item of page) { + const agentId = agentJournalItemSubagentId(item) + if (agentId !== null) { + unnamed.add(agentId) + } + } + for (const item of page) { + for (const entry of rosterEntries(item)) { + unnamed.delete(entry.id) + } + } + if (unnamed.size === 0) { + return undefined + } + const named: AgentSessionSubagentRosterEntry[] = [] + let bytes = 0 + for (const item of journal) { + for (const entry of rosterEntries(item)) { + if (!unnamed.delete(entry.id) || named.length >= MAX_ENTRIES) { + continue + } + const candidate: AgentSessionSubagentRosterEntry = { + itemId: item.itemId, + sequence: item.sequence, + ...(item.sequenceIndex === undefined ? {} : { sequenceIndex: item.sequenceIndex }), + revision: item.revision, + entry + } + const size = Buffer.byteLength(JSON.stringify(candidate), 'utf8') + if (bytes + size <= MAX_BYTES) { + named.push(candidate) + bytes += size + } + } + if (unnamed.size === 0 || named.length >= MAX_ENTRIES) { + break + } + } + return named.length > 0 ? named : undefined +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 1c60029c7d3..52fc3c86b9b 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -1,9 +1,11 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Recovery drives the real journal loader against real on-disk damage: a hole // punched in the row sequence, and a row stamped with a schema this host cannot // read — on both version axes, because only one of them is detectable before a // read. -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,20 +13,28 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from '../agent-session-journal/journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' -import { loadJournal, replayJournal } from '../agent-session-journal/journal-open' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { readJournalEpochRows } from '../agent-session-journal/journal-row-table' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { replayJournal } from '../agent-session-journal/journal-open' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + readTestJournalRows, + deleteTestJournalRow, + insertTestJournalRowJson, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' import type Database from '../../sqlite/sync-database' import { openAgentSessionJournalWithRecovery, - providerHistoryId, - recoveryJournalDir + providerHistoryId } from './agent-session-journal-recovery' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' +import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' -// Only the store's own replay goes through the mock; the probe's, inside the same module, does not. +// The store's replay goes through the mock: it is the only read of the journal an open makes. vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { const actual = await importOriginal<{ replayJournal: typeof replayJournal }>() return { ...actual, replayJournal: vi.fn(actual.replayJournal) } @@ -75,12 +85,12 @@ function item(ordinal: number): AgentJournalItemIdentity { /** Fills a journal with `count` items and hands back its epoch. */ async function seedJournal(count: number): Promise { - const journal = await journals.open({ identity: IDENTITY, journalDir }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) for (let ordinal = 1; ordinal <= count; ordinal += 1) { await journal.appendItem( item(ordinal), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } const epoch = journal.epoch @@ -91,7 +101,7 @@ async function seedJournal(count: number): Promise { /** A journal whose epoch row is gone: every surviving row is unanchored, so a * repair has to set aside the whole range. */ async function seedRepairableSession(): Promise { - const journal = await journals.open({ identity: IDENTITY, journalDir }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -112,18 +122,19 @@ async function withJournalDatabase( directory: string, run: (db: Database.Database) => void ): Promise { - const opened = openJournalDatabase(journalDatabaseFile(directory)) - try { - run(opened.db) - } finally { - opened.db.close() - } + run(openTestJournalHostDatabase(directory).db) +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') } /** The same logical hole `findSequenceGap` detects at replay. */ async function deleteRow(seq: number): Promise { await withJournalDatabase(journalDir, (db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(seq) + deleteTestJournalRow(db, CODEX_SESSION, seq) }) } @@ -158,7 +169,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = journals.track( await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }).then((result) => result.journal) @@ -166,19 +177,19 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(opened.snapshot().items).toHaveLength(2) }) - it('reads the journal once: the probe is the open', async () => { + it("reads the journal once: the store's replay is the open", async () => { await seedJournal(2) vi.mocked(replayJournal).mockClear() const opened = journals.track( await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }).then((result) => result.journal) ) expect(opened.snapshot().items).toHaveLength(2) - expect(replayJournal).not.toHaveBeenCalled() + expect(replayJournal).toHaveBeenCalledOnce() }) it('rebuilds a holed journal in place on a fresh epoch', async () => { @@ -187,7 +198,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -202,75 +213,146 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(texts.some((text) => text.includes('add a retry'))).toBe(true) }) - it('reconstructs a future row-body version into a sibling, never in place', async () => { + // A newer build's row is never rewritten under it, and there is no sibling to write instead: + // the chat reads what it can and refuses writes. + it('opens a future row-body version read-only and leaves it exactly as found', async () => { const epoch = await seedJournal(1) await withJournalDatabase(journalDir, (db) => { - db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ).run( + insertTestJournalRowJson( + db, CODEX_SESSION, - epoch, 3, - 1, JSON.stringify({ v: 99, seq: 3, epoch, kind: 'item', fence: 1, ts: 1 }) ) }) - const opened = journals.track( - await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - journalDir, - fence: 1, - historyFilePath - }).then((result) => result.journal) - ) - - // The unreadable journal is left exactly as found; a newer host still owns it. - await withJournalDatabase(journalDir, (db) => { - const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) - expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true) - expect(rows).toHaveLength(3) - }) - await opened.close() - await withJournalDatabase(recoveryJournalDir(journalDir), (db) => { - const sibling = db.prepare('SELECT row_json FROM journal_rows').all() - expect(JSON.stringify(sibling)).toContain('add a retry') - }) - }) - - it('reconstructs a future database version into a sibling, never in place', async () => { - await seedJournal(1) - await withJournalDatabase(journalDir, (db) => - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - ) - const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ - trigger: 'schema_unreadable', - reset: 'schema_unreadable' + + expect(opened.recovery).toBeNull() + expect(opened.journal.isReadOnly).toBe(true) + // A send says to update, as a database a newer Orca wrote does. + const sent = await performSend( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. + { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, + { + clientMessageId: 'client-after-downgrade', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + ) + expect(sent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - // No schema change, no row written, no row deleted. await withJournalDatabase(journalDir, (db) => { - expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION + 1) - expect(db.prepare('SELECT count(*) AS total FROM journal_rows').get()).toMatchObject({ - total: 2 - }) + const rows = readTestJournalRows(db, CODEX_SESSION, epoch) + expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true) + expect(rows).toHaveLength(3) }) }) + // A downgrade: the database a newer Orca stamped opens read-only. The chat shows its history, a + // send says to update, and the file is left byte-identical. + it('shows a chat from a database a newer Orca stamped, and a send says to update', async () => { + await seedJournal(2) + await withJournalDatabase(journalDir, (db) => { + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + }) + closeTestJournalHostDatabases() + const path = journalDatabasePath(journalDir) + const before = await digest(path) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + database: openTestJournalHostDatabase(journalDir), + fence: 1, + historyFilePath + }) + journals.track(opened.journal) + + expect(opened.recovery).toBeNull() + expect(opened.journal.isReadOnly).toBe(true) + const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) + expect(texts.filter((text) => /item-[12]/.test(text))).toHaveLength(2) + const sent = await performSend( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. + { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, + { + clientMessageId: 'client-after-downgrade', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + ) + expect(sent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } + }) + await journals.closeAll() + expect(await digest(path)).toBe(before) + }) + + // T-logical-per-chat: one file holds every chat, and damage one chat's replay finds is still + // that chat's alone. Only it rebuilds; a neighbour's rows and epoch are not touched. + it('rebuilds only the damaged chat and leaves another chat in the same file untouched', async () => { + const neighbour: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'neighbour-session', + providerHandle: { kind: 'codex', threadId: 'neighbour-thread' } + } + const other = await journals.open({ identity: neighbour, stateDirectory: journalDir }) + for (let ordinal = 1; ordinal <= 3; ordinal += 1) { + await other.appendItem( + item(ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `n-${ordinal}` }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const neighbourEpoch = other.epoch + await other.close() + const database = openTestJournalHostDatabase(journalDir) + const neighbourBefore = readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch) + await seedJournal(3) + await withJournalDatabase(journalDir, (db) => { + updateTestJournalRowJson(db, CODEX_SESSION, 3, '}{') + }) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + database, + fence: 1, + historyFilePath + }) + journals.track(opened.journal) + + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir }) + expect(reopened.epoch).toBe(neighbourEpoch) + expect(readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( + neighbourBefore + ) + }) + // The rehydrate deletes every live row to publish its replacement epoch, so // everything replay rejected is gone for good by the time the import runs. // Orca minted the submission, receipt and lifecycle identities; no provider // transcript can hand them back. it('rebuilds from provider history when the epoch row itself is gone', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -290,7 +372,8 @@ describe('openAgentSessionJournalWithRecovery', () => { { kind: 'item', identity: { provider: 'orca', clientMessageId: 'approval-1' }, - body: { kind: 'status', text: 'approved' } + body: { kind: 'status', text: 'approved' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE } ] }) @@ -300,7 +383,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -318,7 +401,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: join(root, 'missing.jsonl') }) @@ -341,7 +424,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: empty }) @@ -353,19 +436,19 @@ describe('openAgentSessionJournalWithRecovery', () => { await first.journal.close() // The deletion is durable, so the demand for a rebuild has to be too. - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) // A readable transcript rebuilds the epoch, and THAT is what retires it. const retried = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) journals.track(retried.journal) expect(retried.recovery?.imported).toBeGreaterThan(0) await retried.journal.close() - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false }) }) // The reproduced path. Deleting sequence 1 leaves every surviving row @@ -379,7 +462,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: missing }) @@ -389,10 +472,10 @@ describe('openAgentSessionJournalWithRecovery', () => { await first.journal.close() // Reopen: the epoch still holds nothing but the repair, so recovery runs again. - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) const reopened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: missing }) @@ -405,12 +488,12 @@ describe('openAgentSessionJournalWithRecovery', () => { await reopened.journal.appendItem( item(2), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const epoch = reopened.journal.epoch await reopened.journal.close() await withJournalDatabase(journalDir, (db) => { - const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) + const rows = readTestJournalRows(db, CODEX_SESSION, epoch) expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1 }) }) }) @@ -426,7 +509,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: empty }) @@ -439,7 +522,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const epoch = first.journal.epoch await first.journal.close() await withJournalDatabase(journalDir, (db) => { - const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) + const rows = readTestJournalRows(db, CODEX_SESSION, epoch) expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1, @@ -448,12 +531,12 @@ describe('openAgentSessionJournalWithRecovery', () => { }) // The session still reports corrupt, so the next attach retries. - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) // And a transcript that DOES have content still rebuilds the timeline. const retried = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -469,7 +552,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: join(root, 'missing.jsonl') }) @@ -478,7 +561,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const retried = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 29d324be971..42da9a986da 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -1,28 +1,24 @@ -// Journal recovery: rehydrate the timeline from provider history. +// Journal recovery: rehydrate a chat's timeline from provider history. // -// Two triggers, and they need different destinations. A journal whose prefix is -// unusable is writable, so it is rebuilt in place on a fresh epoch. A journal -// written by a NEWER schema is not writable by this host at all — rebuilding it -// in place would fork the sequence space a newer host still owns — so the -// reconstruction goes to a schema-scoped sibling directory that is only ever -// written by hosts at this version and is never merged back. +// One trigger: a chat whose replayed prefix is unusable (a gap, an unanchored prefix, a malformed +// row). Its journal stays writable, so it is rebuilt in place on a fresh epoch, and only that chat +// is touched. Damage SQLite itself reports is not recovered here: the open fails, and the chat +// says it cannot be loaded. import type { AgentType } from '../../../shared/agent-status-types' -import { - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentJournalResetReason, - type AgentSessionJournalIdentity, - type AgentSessionProviderHandle +import type { + AgentJournalResetReason, + AgentSessionJournalIdentity, + AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' -import { loadJournal } from '../agent-session-journal/journal-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' export type AgentSessionJournalRecovery = { - trigger: 'journal_corrupt' | 'schema_unreadable' - /** What subscribers are told; both force a clean snapshot reload. */ + trigger: 'journal_corrupt' + /** What subscribers are told; it forces a clean snapshot reload. */ reset: AgentJournalResetReason epoch: string imported: number @@ -36,11 +32,6 @@ export type AgentSessionJournalOpened = { recovery: AgentSessionJournalRecovery | null } -/** Where a reconstruction lands when the real journal cannot be written. */ -export function recoveryJournalDir(journalDir: string): string { - return `${journalDir}-recovered-v${AGENT_SESSION_JOURNAL_SCHEMA_VERSION}` -} - /** The provider's own session id, which is what the transcript readers index * by — never the Orca session id. */ export function providerHistoryId(handle: AgentSessionProviderHandle): string { @@ -52,52 +43,24 @@ export function providerHistoryId(handle: AgentSessionProviderHandle): string { export async function openAgentSessionJournalWithRecovery(input: { identity: AgentSessionJournalIdentity - journalDir: string + database: JournalHostDatabase fence: number /** Resolve directly to a transcript instead of discovering it by session id. */ historyFilePath?: string | null + deferPerSessionImport?: boolean }): Promise { - const probe = loadJournal(input.journalDir, input.identity.sessionId) - if (probe?.readOnly) { - const journal = await openAgentSessionJournal({ - identity: input.identity, - journalDir: recoveryJournalDir(input.journalDir) - }) - return { journal, recovery: await rehydrateOrClose(input, journal, 'schema_unreadable') } - } const journal = await openAgentSessionJournal({ identity: input.identity, - journalDir: input.journalDir, - // The probe is this open's replay; omitted, not `null`, when there was nothing to load. - ...(probe ? { loaded: probe } : {}) + database: input.database, + deferPerSessionImport: input.deferPerSessionImport }) - if (!probe?.corrupt) { + if (!journal.needsRebuild) { return { journal, recovery: null } } - // `open()` drops the unusable suffix; a successful import rolls once more so - // the rebuilt timeline is the only content of its epoch. - return { journal, recovery: await rehydrateOrClose(input, journal, 'journal_corrupt') } -} - -/** `importLegacyTranscriptIntoJournal` can THROW rather than report `ok: false` - * — a journal write failure, for instance — and nothing else holds a reference - * to the journal this function just opened. A close that rejects is retryable, - * so the journal is retained rather than dropped with its handle still open. */ -async function rehydrateOrClose( - input: { - identity: AgentSessionJournalIdentity - fence: number - historyFilePath?: string | null - }, - journal: AgentSessionJournal, - trigger: AgentSessionJournalRecovery['trigger'] -): Promise { - try { - return await rehydrate({ ...input, journal, trigger }) - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(journal) - throw error - } + // `open()` dropped the unusable suffix; a successful import rolls once more so + // the rebuilt timeline is the only content of its epoch. A throw leaves nothing to release: + // the store holds no connection. + return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } } async function rehydrate(input: { @@ -107,8 +70,7 @@ async function rehydrate(input: { historyFilePath?: string | null trigger: AgentSessionJournalRecovery['trigger'] }): Promise { - const reset: AgentJournalResetReason = - input.trigger === 'schema_unreadable' ? 'schema_unreadable' : 'epoch_changed' + const reset: AgentJournalResetReason = 'epoch_changed' const result = await importLegacyTranscriptIntoJournal({ journal: input.journal, agent: input.identity.agent satisfies AgentType, diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts new file mode 100644 index 00000000000..5ff159ce2e6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -0,0 +1,135 @@ +// One subscriber's catch-up: page it forward to the journal head, or hand it +// the empty caught-up frame its per-emit fields still owe it. Split from the +// subscriber registry so the registry stays the bookkeeping and this stays the +// paging policy. + +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionBackgroundTaskState, + type AgentSessionSlashCommand, + type AgentSessionSubscribeEvent, + type AgentSessionTurnActivity +} from '../../../shared/agent-session-wire' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import { createAgentSessionCatchUpReader } from './agent-session-history-page' +import { + subscriberQueuedMessagesChanged, + type SubscriberFieldHooks +} from './agent-session-subscriber-frame-fields' +import type { Subscriber } from './structured-agent-session-subscribers' + +export type SubscriberDeliveryPort = { + hooks: SubscriberFieldHooks & { + readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + } + emit: ( + subscriber: Subscriber, + event: AgentSessionSubscribeEvent, + options?: { withholdQueued?: boolean } + ) => void + isActive: (subscriber: Subscriber) => boolean + activity: (sessionId: string) => AgentSessionTurnActivity | null +} + +export function deliverToSubscriber( + port: SubscriberDeliveryPort, + input: { + subscriber: Subscriber + journal: AgentSessionJournal + hostNow: number + emitCheckpoint: boolean + backgroundTasks?: AgentSessionBackgroundTaskState | null | undefined + activity?: AgentSessionTurnActivity | null | undefined + } +): void { + const { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } = input + const checkpointActivity = emitCheckpoint ? port.activity(subscriber.sessionId) : undefined + const publishedActivity = activity !== undefined ? activity : checkpointActivity + const shared = { + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), + ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) + } + // Caught up, so there are no rows to read: every publish behind a commit's own delivery. + if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + emitCaughtUp(port, subscriber, emitCheckpoint, shared) + return + } + const readPage = createAgentSessionCatchUpReader(journal) + while (true) { + const result = readPage({ + sessionId: subscriber.sessionId, + direction: 'after', + cursor: subscriber.cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + if (!result.ok) { + const page = { ...result.page, fence: subscriber.fence } + port.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: result.reset, + page, + fence: subscriber.fence, + ...shared + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + return + } + const page = result.page + const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence + if (!advanced) { + emitCaughtUp(port, subscriber, emitCheckpoint, shared) + return + } + port.emit( + subscriber, + { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + ...shared + }, + // On a multi-page catch-up the draft list rides only the final page, or a + // consumed card would vanish pages before its bubble arrives. + { withholdQueued: page.hasNewer } + ) + subscriber.cursor = page.window.nextCursor + if (!page.hasNewer || !port.isActive(subscriber)) { + return + } + } +} + +function emitCaughtUp( + port: SubscriberDeliveryPort, + subscriber: Subscriber, + emitCheckpoint: boolean, + shared: { + hostNow: number + backgroundTasks?: AgentSessionBackgroundTaskState | null + activity?: AgentSessionTurnActivity | null + } +): void { + const commandsChanged = + port.hooks.readCommands !== undefined && + (port.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands + const queuedChanged = subscriberQueuedMessagesChanged(port.hooks, subscriber) + if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) { + port.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + ...shared + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts new file mode 100644 index 00000000000..eebec1e6d1b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts @@ -0,0 +1,77 @@ +// Which per-emit fields ride one subscriber frame: the provider command catalog +// and the queue publication (the draft list with the queue's pause). Both are +// identity-deduplicated against the LAST VALUE SENT — never advanced on a frame +// that withheld the field, or the final replacement would be suppressed — and +// both attach whole to hydrating frames. + +import type { + AgentSessionSlashCommand, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import type { QueuePublication } from './structured-agent-session-queued-publication' + +export type SubscriberFieldState = { + sessionId: string + commands?: AgentSessionSlashCommand[] | null + /** The last queue publication actually SENT. */ + queuePublication?: QueuePublication +} + +export type SubscriberFieldHooks = { + readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + readQueuePublication?: (sessionId: string) => QueuePublication | undefined +} + +export type SubscriberFrame = { + frame: AgentSessionSubscribeEvent + commands: AgentSessionSlashCommand[] | null + attachedQueued: boolean + queued: QueuePublication | undefined +} + +/** Builds the frame to emit; the caller stores the returned refs only after the + * emit succeeded, so a dropped subscriber never advances its dedup state. */ +export function buildSubscriberFrame( + hooks: SubscriberFieldHooks, + subscriber: SubscriberFieldState, + event: AgentSessionSubscribeEvent, + withholdQueued: boolean +): SubscriberFrame { + const commands = hooks.readCommands?.(subscriber.sessionId) ?? null + const includeCommands = + hooks.readCommands !== undefined && + event.type !== 'end' && + (event.type !== 'batch' || commands !== subscriber.commands) + // Withheld on intermediate catch-up pages (the caller says so), attached to + // every hydrating frame, and to batches only when the list changed. + const queued = withholdQueued ? undefined : hooks.readQueuePublication?.(subscriber.sessionId) + const attachedQueued = + queued !== undefined && + event.type !== 'end' && + (event.type !== 'batch' || queued !== subscriber.queuePublication) + return { + frame: { + ...event, + ...(includeCommands ? { commands: commands ?? null } : {}), + ...(attachedQueued && queued + ? { queuedMessages: queued.queuedMessages, queuePause: queued.queuePause } + : {}) + }, + commands, + attachedQueued, + queued + } +} + +/** Whether a caught-up publish with no rows still owes this subscriber a frame: + * draft inserts and pause changes write no journal row, so an unchanged cursor + * must still deliver the changed publication. */ +export function subscriberQueuedMessagesChanged( + hooks: SubscriberFieldHooks, + subscriber: SubscriberFieldState +): boolean { + return ( + hooks.readQueuePublication !== undefined && + hooks.readQueuePublication(subscriber.sessionId) !== subscriber.queuePublication + ) +} diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index cdee04cf2bf..55b338366f6 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../agent-session-journal/journal-payload-bounds' -import { CODEX_APP_SERVER_NOTIFICATION_METHODS } from '../../codex/codex-app-server-notification-schema' -import { CLAUDE_STREAM_JSON_FRAME_KINDS } from './claude-stream-json-frame-schema' import { classifyProviderFrame, isDeltaProviderFrameKind, @@ -10,18 +8,6 @@ import { import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame' describe('provider frame classification catalog', () => { - it('classifies every pinned Codex app-server notification method', () => { - expect(Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.codex)).toEqual([ - ...CODEX_APP_SERVER_NOTIFICATION_METHODS - ]) - }) - - it('classifies every pinned Claude stream-json frame kind', () => { - expect(Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.claude)).toEqual([ - ...CLAUDE_STREAM_JSON_FRAME_KINDS - ]) - }) - it('classifies every pinned delta kind as stream-into-item', () => { const deltaKinds = [ ...Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.codex), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb4ee607e38..b7371ee40f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A send is accepted, then delivered: the host answers once the message is recorded, and the // session's delivery loop starts a provider child for it and hands it over. Against the real host, // store and journal; each assertion reads what an open chat or the journal's next reader sees. @@ -19,7 +20,7 @@ import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { @@ -43,6 +44,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -85,7 +87,7 @@ async function startHost(): Promise { setOption: vi.fn(async () => undefined), ...adapterExtras }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: 5, idleMs }, @@ -108,7 +110,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) await startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) @@ -237,10 +239,7 @@ async function writeAsEarlierProcess( }) const journal = await openAgentSessionJournal({ identity: journalIdentityFor(record, params), - journalDir: journalDirectoryFor(root, { - workspaceId: record.location.workspaceId, - sessionId: SESSION - }) + database: openTestJournalHostDatabase(root) }) await write(journal, record.lease.runtimeFence) await journal.close() @@ -545,7 +544,12 @@ describe('what an earlier host process left behind', () => { await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) - await journal.resolveDispatch({ clientMessageId: 'handed', state: 'pending', fence }) + await journal.resolveDispatch({ + clientMessageId: 'handed', + state: 'pending', + fence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) }) await host.flushAllStreamedEvents() await startHost() @@ -819,7 +823,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { await startHost() } - it('settles an interrupted compaction at open, so a send is accepted and delivered (R16)', async () => { + it("ignores an older build's interrupted compaction, so a send is accepted and delivered (R16)", async () => { await leftPrepared((fence) => store.setConversationCommand(SESSION, fence, { command: 'compact', @@ -834,10 +838,8 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the compaction') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' - }) + // Nothing settles the record: it belongs to a child this host no longer runs. + expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ phase: 'prepared' }) }) it('completes a rewind the provider already applied at open, so a send is accepted (R16)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 7c12909e036..a0d2f0fdb7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -5,7 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' @@ -18,6 +19,7 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -115,10 +117,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { describe('structured session acquisition options', () => { it('samples provider history before acquiring a replacement child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const initialStore = await openTestAgentSessionRecordStore(root) let childAcquired = false const historyWindow = (): ProviderHistoryWindow => ({ items: [], @@ -154,8 +153,7 @@ describe('structured session acquisition options', () => { const first = await performAttach({ store: initialStore, adapter: withHistory('created'), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -181,10 +179,7 @@ describe('structured session acquisition options', () => { fence: 1 }) await firstJournal!.close() - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 @@ -195,8 +190,7 @@ describe('structured session acquisition options', () => { const second = await performAttach({ store, adapter: withHistory('resumed'), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -219,10 +213,7 @@ describe('structured session acquisition options', () => { it('persists create defaults before the first provider acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created' }) const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } const recordPhase = vi.fn() @@ -230,8 +221,7 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -254,17 +244,13 @@ describe('structured session acquisition options', () => { it('replays a create retried after the host re-resolved different options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ store, adapter: sessionAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken, claimKeyId: 'key-1', @@ -289,14 +275,12 @@ describe('structured session acquisition options', () => { it('persists provider options before proving a resumed legacy record', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const created = await performAttach({ store, adapter: adapter({ origin: 'created' }), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -317,10 +301,7 @@ describe('structured session acquisition options', () => { now: NOW }) - const resumedStore = await AgentSessionRecordStore.open({ - directory: storeDir, - hostId: 'local' - }) + const resumedStore = await openTestAgentSessionRecordStore(root) await resumedStore.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 @@ -335,8 +316,7 @@ describe('structured session acquisition options', () => { models: [] } }), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -350,7 +330,7 @@ describe('structured session acquisition options', () => { }) expect(resumed).toMatchObject({ ok: true }) - const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(root) expect(reopened.getRecord(SESSION)?.options).toEqual({ approvalPolicy: 'on-request', personality: 'concise', @@ -362,10 +342,7 @@ describe('structured session acquisition options', () => { it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created', options: { current: { model: 'gpt-standard' }, models: [] }, @@ -375,8 +352,7 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -398,10 +374,7 @@ describe('structured session acquisition options', () => { it('releases an acquisition when provider options cannot be read', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const releaseAcquisition = vi.fn(async () => true) const failingAdapter: StructuredAgentSessionAdapter = { ...adapter({ origin: 'created' }), @@ -415,8 +388,7 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: failingAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -453,8 +425,7 @@ describe('structured session acquisition options', () => { ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { const exitProven = cleanup === true root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const base = adapter({ origin: 'created', options: { current: { model: 'gpt-5.6-terra' }, models: [] } @@ -510,8 +481,10 @@ describe('structured session acquisition options', () => { performAttach({ store: target, adapter: failingAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!, failingAdapter), + openConversation: openTestAttachConversation( + openTestJournalHostDatabase(root!), + failingAdapter + ), authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', @@ -539,10 +512,7 @@ describe('structured session acquisition options', () => { exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' )) - const reopened = await AgentSessionRecordStore.open({ - directory: storeDir, - hostId: 'local' - }) + const reopened = await openTestAgentSessionRecordStore(root) const failedRecord = reopened.getRecord(SESSION) expectSettledAttachLease(failedRecord) expect( @@ -605,15 +575,14 @@ describe('structured session acquisition options', () => { describe('the tab a create reserves', () => { async function openStore() { root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + return openTestAgentSessionRecordStore(root) } function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { return performAttach({ store, adapter: adapter({ origin: 'created' }), - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 7c1b5cde6dc..4ca9e774739 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -15,6 +15,7 @@ import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalDispatchState, + AgentJournalTurnItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' @@ -32,12 +33,14 @@ import { isAgentSessionWireRefusalCode, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { + ProviderDiagnostic, + SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { @@ -141,6 +144,23 @@ export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessi return error instanceof Error && error.name === 'AgentSessionPreSpawnError' } +/** A conversation command the host handed to a provider child: the running turn it opened for it, + * which the child's translator ends, and where the command's own result row goes. */ +export type StructuredAgentSessionCommandRun = { + clientMessageId: string + /** What a Stop names. */ + turnId: string + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The turn record as the host wrote it; the translator's end revises it. */ + running: AgentJournalTurnItem +} + +/** A command the provider took answers it in place and echoes no item of its own. */ +export type AgentSessionCommandAdmission = + | AgentSessionDispatchOutcome + | { state: 'accepted'; providerIdentity: null } + export type AgentSessionDispatchOutcome = /** The provider owns the turn now, under this identity. */ | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } @@ -223,6 +243,12 @@ export type AgentSessionBackgroundTaskStops = { supportsStopAll: boolean } +/** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. */ +export type AgentSessionCancelOutcome = { + cancelled: boolean + refusal?: { detail?: ProviderDiagnostic } +} + export type StructuredAgentSessionAdapter = { /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean @@ -265,31 +291,33 @@ export type StructuredAgentSessionAdapter = { onPrepared?: ( items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] ) => Promise - onReverted?: () => Promise }): Promise< | { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } | { ok: false; reason: AgentSessionRewindReason } > + /** Sends a conversation compaction and answers with the provider's receipt of it, as a dispatch + * does. The command's turn is the child's journal translator's to end, from the provider's own + * frames; a child that ends first leaves it to the host's settlement of that child. */ compact?(input: { - turnId: string sessionId: string fence: number - onLateResult?: (result: StructuredSessionCompactionResult) => Promise - }): Promise + command: StructuredAgentSessionCommandRun + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill - * a turn the client never asked to stop. */ + * a turn the client never asked to stop. With no `turnId` the conversation asked to stop + * everything it has in flight — a running turn, or a dispatch whose turn has not opened yet. */ cancelTurn(input: { sessionId: string - turnId: string + turnId?: string fence: number prompt?: { itemId: string } /** Latest journal submission for this fence, when the host has one. */ dispatchStatus?: { state: AgentJournalDispatchState; recovered: boolean } | null /** Re-reads the turn the published journal says is running — the only turn a client - * could have named. A function, not a value, because the guard re-checks after the - * delivery fence may have waited. Absent for direct callers with no journal. */ + * could have named. A function, not a value, because the guard reads it when the + * interrupt is issued. Absent for direct callers with no journal. */ resolveLiveTurnId?: () => string | null - }): Promise<{ cancelled: boolean }> + }): Promise /** Changes the provider thread's goal. `rejected` is the provider refusing the * change; a throw leaves its effect unknown. Absent where no goal exists. */ changeThreadGoal?(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 4b8e1d87ba3..6bb43fb9d31 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Source validation must finish before a new session claims the provider conversation. import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' @@ -5,7 +6,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { attachFingerprintFields, @@ -14,9 +16,9 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -118,12 +120,11 @@ async function attach( sessionAdapter: StructuredAgentSessionAdapter, onAttached: AttachFlowInput['onAttached'] = () => {} ) { - store ??= await AgentSessionRecordStore.open({ directory: join(root!, 'store'), hostId: 'local' }) + store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ store, adapter: sessionAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -162,7 +163,7 @@ describe('adopting a provider conversation on create', () => { await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() }) @@ -220,24 +221,21 @@ describe('adopting a provider conversation on create', () => { vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( new Error('disk write failed') ) - const close = vi.spyOn(agentSessionJournalCloseRetries, 'closeOrRetain') const sessionAdapter = adapter() await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) - // The journal is the conversation's, not the attach's: a failed import closes nothing. - expect(close).not.toHaveBeenCalled() }) it('leaves the conversation writable when the import fails after acquiring', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'valid source') - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 55128a23c9c..bda7cc9330f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // What an open chat receives, asserted at its subscriber rather than in the journal: a fresh // subscribe re-reads the journal and hides a write that never reached the readers already open. @@ -11,7 +12,8 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -23,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -123,7 +126,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, providerChildPhase: 'starting' as const })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -135,7 +138,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW @@ -176,7 +179,11 @@ describe('an open chat receives every row its journal commits', () => { const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } const body = { kind: 'status' as const, text: 'context usage answered after the turn' } - expect(providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }))).toEqual({ + expect( + providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) await host.flushStreamedEvents(SESSION) @@ -194,7 +201,10 @@ describe('an open chat receives every row its journal commits', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'host-note' }, { kind: 'status', text: 'written by a writer that publishes nothing' }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) @@ -213,7 +223,8 @@ describe('an open chat receives each row once', () => { sink.appendItem( { provider: 'orca', clientMessageId: 'streamed' }, - { kind: 'status', text: 'streamed row' } + { kind: 'status', text: 'streamed row' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink.publish() await host.flushStreamedEvents(SESSION) @@ -233,7 +244,10 @@ describe('an open chat receives each row once', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'host-row' }, { kind: 'status', text: 'host row' }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) host['subscribers'].publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8fdee5c62bd..c469035c468 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -48,7 +48,6 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter - journalRoot: string authority: AgentSessionAttachAuthority callerKey: string params: AgentSessionAttachParams @@ -227,7 +226,6 @@ export async function performAttach( attached = await attachJournal({ record, params, - journalRoot: input.journalRoot, adapter: input.adapter, openConversation: input.openConversation, providerHistoryWindow diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 40804bf55e2..9cfdf6a9b87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,6 +1,5 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. // // Attach is the one operation that touches every collaborator the host owns — the lease @@ -136,7 +135,6 @@ async function runAttach( const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, - journalRoot: context.deps.journalRoot, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. onAcquiring: async () => { @@ -210,7 +208,6 @@ async function runAttach( context.deps.adapter, context.now ) - await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal, fence) } else { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 36f6ebc79e8..e22c5dea9fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -10,9 +10,11 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { @@ -68,10 +70,7 @@ function adapterWith(providerHistoryWindow?: () => Promise { it('leaves a message the open conversation still has queued alone (W4′e)', async () => { const journal = await journals.open({ identity: IDENTITY, - journalDir: journalDirectoryFor(root, { - workspaceId: IDENTITY.workspaceId, - sessionId: IDENTITY.sessionId - }) + stateDirectory: root }) await journal.appendSubmission({ clientMessageId: 'queued', @@ -185,8 +180,6 @@ describe('attachJournal restart reconciliation', () => { const attached = await attachJournal({ record: RECORD, params: PARAMS, - journalRoot: root, - adapter, openConversation: async () => journal }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index 1e2322cbc6d..aa0ae9e29e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' @@ -6,12 +7,12 @@ import { openStructuredAgentSessionConversationJournal } from './structured-agen /** For a test that attaches without a host: the conversation's journal, through the one open a * host would take, so the attach under test adopts it the way it adopts the host's. */ export function openTestAttachConversation( - journalRoot: string, + journalDatabase: JournalHostDatabase, adapter: Pick = {} ): (record: AgentSessionRecord) => Promise { return async (record) => ( - await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record, { + await openStructuredAgentSessionConversationJournal({ journalDatabase, adapter }, record, { acquisition: true }) ).session.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 10c4866cea3..85f99056c3a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -184,7 +184,6 @@ export type AttachedJournal = { export async function attachJournal(input: { record: AgentSessionRecord params: AgentSessionAttachParams - journalRoot: string adapter: StructuredAgentSessionAdapter /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index d1c2e8dd917..55ac742ec15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -7,6 +7,8 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' import { structuredStripChildWork } from '../../../shared/agent-child-work-listing' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' +import { tryReadQueuePublication } from './structured-agent-session-queued-publication' +import type { AgentSessionHistoryScope } from './agent-session-history-page' import type { AgentSessionSubscribers, AgentSessionSubscribeInput @@ -34,19 +36,31 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined ) {} - async history(request: AgentSessionHistoryRequest): Promise { + /** `scope` is for in-process readers; a wire request reads every agent's rows. */ + async history( + request: AgentSessionHistoryRequest, + scope?: AgentSessionHistoryScope + ): Promise { + const journal = (await this.conversation(request.sessionId)).journal const result = readStructuredAgentSessionHistoryResult({ - journal: (await this.conversation(request.sessionId)).journal, + journal, record: this.deps.store.getRecord(request.sessionId), - request + request, + scope }) const backgroundTasks = this.state(request.sessionId) + const queue = tryReadQueuePublication(journal) const hostNow = this.deps.now?.() ?? Date.now() return { ...result, page: { ...result.page, hostNow, + // A stale history answer never replaces newer live subscription state; + // the client's reducer keeps live-over-history precedence. + ...(queue !== undefined + ? { queuedMessages: queue.queuedMessages, queuePause: queue.queuePause } + : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts new file mode 100644 index 00000000000..b456bacac26 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -0,0 +1,293 @@ +// Stop and `/compact` on the shipping Claude adapter, driven by the frames a stream-json child +// writes: which result ends the command, and what the next message is handed to. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from '../../claude/claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let claude: ReturnType +/** The adapter's clock, which a real child's exit reads at a different instant than the host's. */ +let adapterNow = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-compact-stop-')) + resetHostTestOperationIds() + adapterNow = NOW + 7 + // No echo of its own: each test writes the frames Claude would. + claude = fakeClaude({ replayUuid: null }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + // The runtime's own wiring: the provider's answer is what settles a send it took. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => adapterNow + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + // The production router is what declares create support; the bare adapter only knows locations. + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + expect(attached).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function compact(): Promise { + const params = { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } + await expect(host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + return params.envelope.clientOperationId +} + +function stop(cmid: string) { + const { turnId } = structuredAgentSessionCommandTurn(cmid) + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +/** The uuid of the frame that carried `text` to Claude, and the child it went to. */ +async function sent(text: string): Promise<{ connection: FakeConnection; uuid: string }> { + return vi.waitFor(() => { + for (const connection of claude.connections) { + const frame = connection.sent.find((message) => JSON.stringify(message).includes(text)) + if (frame) { + return { connection, uuid: String(frame.uuid) } + } + } + throw new Error(`nothing sent ${text}`) + }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function result(uuid: string, overrides: Record = {}): Record { + return { + type: 'result', + subtype: 'success', + is_error: false, + uuid: `result-${uuid}`, + user_message_uuid: uuid, + ...overrides + } +} + +const INTERRUPTED = { + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming' +} + +async function commandState(cmid: string) { + await host.flushStreamedEvents(SESSION) + const { itemId } = structuredAgentSessionCommandTurn(cmid) + const snapshot = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(snapshot.items.find((item) => item.itemId === itemId)?.body) +} + +it('ends a hung /compact Claude will not interrupt by stopping it, and answers the next send in its own turn', async () => { + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'Claude did not answer the interrupt.') + } + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + + await expect(stop(cmid)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + + const body = hostTestMessage('what next?') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const { connection, uuid } = await sent('what next?') + expect(compacting.closed).toBe(true) + expect(connection).not.toBe(compacting) + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'what next?' }] } + }) + frame(connection, { + type: 'assistant', + uuid: 'answer', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Here is.' }] } + }) + frame(connection, result(uuid)) + + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + const answer = snapshot.items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(answer?.turnScope).toMatchObject({ kind: 'turn' }) + expect(answer?.turnScope).not.toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) + +it('ends a stopped /compact on its own interrupted result, and a late copy of that result does not end the next one', async () => { + claude.routes.interrupt = () => ({}) + const first = await compact() + const { connection, uuid: firstUuid } = await sent('/compact') + + await expect(stop(first)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + // The interrupt was taken: the command runs until Claude answers it. + expect((await commandState(first))?.state).toBe('running') + frame(connection, result(firstUuid, INTERRUPTED)) + await vi.waitFor(async () => + expect(await commandState(first)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + + const second = await compact() + await vi.waitFor(() => + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('/compact')) + ).toHaveLength(2) + ) + const secondUuid = String(connection.sent.at(-1)?.uuid) + frame(connection, result(firstUuid, INTERRUPTED)) + frame( + connection, + result('an-earlier-send', { subtype: 'error_during_execution', is_error: true }) + ) + expect((await commandState(second))?.state).toBe('running') + + frame(connection, { type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame(connection, result(secondUuid)) + await vi.waitFor(async () => + expect(await commandState(second)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) +}) + +it('settles a /compact whose Claude child exits mid-command through that child, and delivers what waited', async () => { + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + const body = hostTestMessage('after the exit') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + // Claude sends no end frame at all: its child is gone. + compacting.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + const { connection } = await sent('after the exit') + expect(connection).not.toBe(compacting) +}) + +it('tells why a /compact ended when its Claude child exits after taking it', async () => { + const cmid = await compact() + const { connection, uuid } = await sent('/compact') + // Claude echoes the command, so the send is answered; then the child dies with no end frame. + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: '/compact' }] } + }) + await host.flushStreamedEvents(SESSION) + adapterNow = NOW + 7 + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1)')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + // The exit's words are the host's sentence, never Claude's log text. + const exitRow = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerExited' + ) + expect(exitRow?.body).toMatchObject({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + }) + expect(exitRow?.turnScope).toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts new file mode 100644 index 00000000000..1ba4f13e4ea --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -0,0 +1,148 @@ +// Claude's echo of a sent message both answers the send and opens its turn. Whatever order the +// host publishes those in, a chat reading its frames one at a time must read working throughout: +// Stop and every session list's Working come from that one rule. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +const claude = { current: fakeClaude({ replayUuid: null }) } + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-echo-working-')) + resetHostTestOperationIds() + claude.current = fakeClaude({ replayUuid: null }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + // As the runtime wires it. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.current.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function settled(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)) +} + +it('reads working at every published frame from the send through the echo that opens its turn', async () => { + const submissions = new Map() + const turns = new Map() + const working: boolean[] = [] + const fence = store.getRecord(SESSION)!.lease.runtimeFence + host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + if (event.type !== 'batch') { + return + } + for (const submission of event.batch.submissions) { + submissions.set(submission.clientMessageId, submission) + } + for (const item of event.batch.items) { + if (item.body.kind === 'turn') { + turns.set(item.itemId, item.body.state) + } + } + const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null + working.push( + isStructuredAgentSessionMainAgentWorking(running, [...submissions.values()], fence) + ) + } + }) + const body = hostTestMessage('hi') + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + await settled() + const connection = claude.current.connections[0]! + // Claude echoes the written message back, which is what opens its turn. + connection.handlers.onMessage?.({ ...connection.sent.at(-1)!, uuid: 'echo-uuid' }) + await settled() + + expect([...submissions.values()].map((submission) => submission.dispatchState)).toEqual([ + 'accepted' + ]) + expect([...turns.values()]).toEqual(['running']) + expect(working).not.toContain(false) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 5b56db5552e..6398d96ab0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -14,6 +15,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -79,11 +81,11 @@ beforeEach(async () => { resetHostTestOperationIds() activeModel = DEFAULT_MODEL optionWritable = Promise.resolve() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-claude', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index e8b8d0f0044..f78be712f7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' @@ -15,8 +16,10 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -77,11 +80,11 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -148,8 +151,12 @@ async function status(): Promise { ) } -it('withdraws a follow-up Claude queued behind the running turn when that turn is stopped', async () => { - const connection = claude.connections[0]! +async function liveTurnId(): Promise { + return activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) +} + +/** Sends the first message and lets Claude open its turn; returns the turn a client reads. */ +async function openFirstTurn(connection: (typeof claude.connections)[number]): Promise { const first = await send('Write a long reply.') await eventually(() => expect(connection.sent).toHaveLength(1)) // Claude opens the turn: its system/init, then the echo of the message it runs. @@ -166,18 +173,38 @@ it('withdraws a follow-up Claude queued behind the running turn when that turn i uuid: connection.sent.at(-1)!.uuid }) await eventually(async () => expect((await dispatch(first)).state).toBe('accepted')) - const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + const turnId = await liveTurnId() expect(turnId).not.toBeNull() + return turnId! +} + +async function endFirstTurn(connection: (typeof claude.connections)[number]): Promise { + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid: 'result-first', + session_id: PROVIDER_SESSION_ID, + is_error: false, + terminal_reason: 'completed', + duration_ms: 12 + }) + await eventually(async () => expect(await liveTurnId()).toBeNull()) +} + +function stop(turnId: string) { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +it('withdraws a follow-up Claude queued behind the running turn when that turn is stopped', async () => { + const connection = claude.connections[0]! + const turnId = await openFirstTurn(connection) const followUp = await send('And then this.') await eventually(() => expect(connection.sent).toHaveLength(2)) queued.push(String(connection.sent.at(-1)!.uuid)) await eventually(async () => expect((await dispatch(followUp)).state).toBe('pending')) - const stopped = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId }), - turnId: turnId! - }) + const stopped = await stop(turnId) expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) // The interrupted turn closes as the CLI ends it. connection.handlers.onMessage?.({ @@ -194,3 +221,98 @@ it('withdraws a follow-up Claude queued behind the running turn when that turn i ) await eventually(async () => expect(await status()).toBe('idle')) }, 15_000) + +// The phone names the turn from its own copy of the journal, which can trail the host's. +it('withdraws a follow-up Claude holds when the turn the Stop names ended before it landed', async () => { + const connection = claude.connections[0]! + const turnId = await openFirstTurn(connection) + const followUp = await send('And then this.') + await eventually(() => expect(connection.sent).toHaveLength(2)) + queued.push(String(connection.sent.at(-1)!.uuid)) + await eventually(async () => expect((await dispatch(followUp)).state).toBe('pending')) + await endFirstTurn(connection) + + expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(connection.calls.find((call) => call.subtype === 'interrupt')?.params).toEqual({ + cancelQueued: true + }) + await eventually(async () => + expect(await dispatch(followUp)).toEqual({ + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + await eventually(async () => expect(await status()).toBe('idle')) +}, 15_000) + +it('withdraws a follow-up still queued on the host when the turn the Stop names already ended', async () => { + const connection = claude.connections[0]! + const turnId = await openFirstTurn(connection) + await endFirstTurn(connection) + // Holds the delivery loop between its start check and the handover, with the follow-up queued. + let release!: () => void + const held = new Promise((resolve) => (release = resolve)) + const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { + await held + }) + const followUp = await send('And then this.') + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === followUp + ) + expect(submission && isQueuedAgentJournalSubmission(submission)).toBe(true) + + // Nothing reached Claude, so the host's withdrawal is the whole Stop, as with no turn named. + expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + release() + await eventually(async () => + expect(await dispatch(followUp)).toEqual({ + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + expect(connection.sent).toHaveLength(1) + await eventually(async () => expect(await status()).toBe('idle')) + const rows = (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) + expect(rows).not.toContain('The provider had already finished this turn.') +}, 15_000) + +it('withdraws a host-queued follow-up but leaves a newer turn running when the Stop names an older one', async () => { + const connection = claude.connections[0]! + const olderTurnId = await openFirstTurn(connection) + await endFirstTurn(connection) + const second = await send('Now this.') + await eventually(() => expect(connection.sent).toHaveLength(2)) + connection.handlers.onMessage?.({ + ...connection.sent.at(-1)!, + uuid: connection.sent.at(-1)!.uuid + }) + await eventually(async () => expect((await dispatch(second)).state).toBe('accepted')) + const newerTurnId = await liveTurnId() + expect(newerTurnId).not.toBeNull() + expect(newerTurnId).not.toBe(olderTurnId) + let release!: () => void + const held = new Promise((resolve) => (release = resolve)) + const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { + await held + }) + const followUp = await send('And then this.') + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + + expect(await stop(olderTurnId)).toMatchObject({ ok: true, value: { cancelled: false } }) + release() + await eventually(async () => + expect(await dispatch(followUp)).toEqual({ + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(await liveTurnId()).toBe(newerTurnId) + const rows = (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) + expect(rows).toContain('The provider had already finished this turn.') +}, 15_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 4a8f0a1440f..7cfd7fe8623 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -8,8 +8,11 @@ import { fakeClaude, identityFor } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' @@ -28,7 +31,8 @@ describe('Claude root-exit stop', () => { it('releases a captured live claim after the provider root exits', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-root-exit-')) roots.push(root) - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const stateDirectory = join(root, 'journal') + const store = await openTestAgentSessionRecordStore(stateDirectory) const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) @@ -75,7 +79,7 @@ describe('Claude root-exit stop', () => { }) const journal = await journals.open({ identity: { ...identityFor(), hostId: 'local', workspaceId: 'folder-1' }, - journalDir: join(root, 'journal') + stateDirectory }) const close = vi.spyOn(journal, 'close') const publishStatus = vi.fn() @@ -112,7 +116,12 @@ describe('Claude root-exit stop', () => { } ] ]) - const deps = { store, adapter, journalRoot: root, claimKeyId: 'key-1' } + const deps = { + store, + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1' + } const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 37d2b8f1307..2efe7dd48f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -3,6 +3,7 @@ import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child- import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -53,6 +54,8 @@ export class StructuredAgentSessionClientDelivery { this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), + readQueuePublication: (sessionId) => + tryReadQueuePublication(sessions.get(sessionId)?.journal), onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts deleted file mode 100644 index f5410de74f6..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// A close that REJECTED did not release the handle. -// -// `AgentSessionJournal.close()` is retryable by design: the release step is -// unguarded precisely so a second call is a second attempt. Callers that did -// `close().catch(() => undefined)` and then threw or overwrote their map entry -// turned that retryable failure into a permanent orphan — on POSIX a silent -// leak, on Windows a handle that blocks renaming or removing the directory. -// -// These drive the REAL callers: the attach orchestration's `onAttached`, and -// host teardown, which is what runtime stop calls. Only the lease/record -// machinery around them is stubbed. - -import { access, mkdtemp, rename, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { - agentSessionJournalCloseRetries, - JournalCloseRetryRegistry -} from '../agent-session-journal/journal-close-retry' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - -const attachFlow = vi.hoisted(() => ({ - journal: null as AgentSessionJournal | null -})) - -// The lease reservation, the record store and the provider child are not what -// these cases are about; `onAttached` is, and it is the real one. -vi.mock('./structured-agent-session-attach-flow', () => ({ - performAttach: async (input: { - onAttached: ( - attached: { journal: AgentSessionJournal; recovery: null }, - generation: string | null - ) => Promise - }) => { - await input.onAttached({ journal: attachFlow.journal!, recovery: null }, null) - return { ok: true, value: {} } - } -})) - -const SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: SESSION } -} - -let root: string -const journals = createTrackedJournalOpener() - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -async function expectNothingHoldsTheDirectory(directory: string): Promise { - const dbPath = journalDatabaseFile(directory) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - // The half that actually fails on Windows when a handle is still open. - const moved = `${directory}-moved` - await rename(directory, moved) - await rm(moved, { recursive: true }) -} - -function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSession { - return { - journal, - params: {} as StructuredAgentSessionHostSession['params'], - child: null - } -} - -/** A journal whose close rejects until `failures` is exhausted, wrapping a real - * store so the handle it holds is a real one. */ -function flakyClose(journal: AgentSessionJournal, failures: number): AgentSessionJournal { - let remaining = failures - return new Proxy(journal, { - get(target, property, receiver) { - if (property !== 'close') { - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. - return Reflect.get(target, property, receiver) - } - return async () => { - if (remaining > 0) { - remaining -= 1 - throw new Error('close rejected') - } - await target.close() - } - } - }) -} - -function attachContext( - sessions: Map -): StructuredAgentSessionAttachContext { - const eventSink = { - sink: {}, - drained: async () => ({ ok: true }) as const, - unbind: () => undefined, - bind: () => undefined, - close: () => undefined - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the attach reads only the members defined here. - return { - deps: { store: { getRecord: () => null }, claimKeyId: 'key-1', journalRoot: root }, - runtimeState: { - resolveRecovery: async () => undefined, - eventSinkFor: () => eventSink, - currentEventSink: () => eventSink, - mintEventSink: () => eventSink, - adoptEventSink: () => undefined, - probeOwner: async () => ({ outcome: 'pid-absent' }), - discardEventSink: () => undefined - }, - sessions, - subscribers: { - reset: () => undefined, - snapshot: () => undefined, - publish: () => undefined - }, - tasks: { trackAttach: (task: Promise) => task }, - reconcileLeases: async () => null, - serialize: (_sessionId: string, task: () => Promise) => task(), - now: () => 1, - publishStatus: () => undefined - } as unknown as StructuredAgentSessionAttachContext -} - -const attachParams = { - envelope: { sessionId: SESSION, clientOperationId: 'op-1' } -} as unknown as Parameters[2] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-close-retry-')) - // The registry is process-wide; drain it so one case cannot see another's. - await agentSessionJournalCloseRetries.retryAll() -}) - -afterEach(async () => { - await agentSessionJournalCloseRetries.retryAll() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('the registry', () => { - it('retains a journal whose close rejected and releases it on the retry', async () => { - const directory = join(root, 'retained') - const registry = new JournalCloseRetryRegistry() - const journal = flakyClose( - await journals.open({ identity: IDENTITY, journalDir: directory }), - 1 - ) - - const first = await registry.closeOrRetain(journal) - expect(first.closed).toBe(false) - expect(registry.pendingDirectories).toEqual([directory]) - - expect(await registry.retryAll()).toEqual([]) - expect(registry.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(directory) - }) -}) - -describe('the attach orchestration', () => { - // The attach adopts the conversation's one open journal; it never opens a second handle, so - // there is no replacement to abort and no provisional journal to close. - it('keeps the journal it adopted indexed and open when an attach succeeds', async () => { - const directory = join(root, 'adopted') - const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) - attachFlow.journal = journal - const sessions = new Map([[SESSION, hostSession(journal)]]) - - await attachStructuredAgentSession(attachContext(sessions), 'caller-1', attachParams) - - expect(sessions.get(SESSION)?.journal).toBe(journal) - await expect( - journal.appendItem( - { provider: 'orca', clientMessageId: 'after-attach' }, - { - kind: 'status', - text: 'still writable' - } - ) - ).resolves.toBeDefined() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) - - it('leaves the conversation indexed and open when the sink barrier fails', async () => { - const directory = join(root, 'adopted-barrier') - const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) - attachFlow.journal = journal - const sessions = new Map([[SESSION, hostSession(journal)]]) - const context = attachContext(sessions) - const failing = { - sink: {}, - drained: async () => ({ ok: false, error: new Error('sink barrier failed') }) as const, - unbind: () => undefined, - bind: () => undefined, - close: () => undefined - } - // The re-attach binds the sink the live child already writes through. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial sink double; the attach reads only drained/bind/close from it. - context.runtimeState.currentEventSink = (() => - failing) as unknown as typeof context.runtimeState.currentEventSink - - await expect(attachStructuredAgentSession(context, 'caller-1', attachParams)).rejects.toThrow( - 'sink barrier failed' - ) - - // A failed attach does not end the conversation: its queued messages and the failure row - // are written into this same journal. - expect(sessions.get(SESSION)?.journal).toBe(journal) - await expect( - journal.appendItem( - { provider: 'orca', clientMessageId: 'after-failure' }, - { - kind: 'status', - text: 'still writable' - } - ) - ).resolves.toBeDefined() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) -}) - -describe('teardown, which is what runtime stop calls', () => { - it('retries the journals earlier failure paths could not close', async () => { - const orphanDir = join(root, 'orphan') - const orphan = flakyClose(await journals.open({ identity: IDENTITY, journalDir: orphanDir }), 1) - expect((await agentSessionJournalCloseRetries.closeOrRetain(orphan)).closed).toBe(false) - - // The first teardown reports the still-failing close instead of hiding it. - await tearDownStructuredAgentSessionHost({ phases: [], sessions: new Map() }) - - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(orphanDir) - }) - - it('surfaces a retained close that still rejects, and keeps it for the next stop', async () => { - const orphanDir = join(root, 'stubborn') - const orphan = flakyClose(await journals.open({ identity: IDENTITY, journalDir: orphanDir }), 2) - await agentSessionJournalCloseRetries.closeOrRetain(orphan) - - await expect( - tearDownStructuredAgentSessionHost({ phases: [], sessions: new Map() }) - ).rejects.toMatchObject({ errors: [expect.objectContaining({ message: 'close rejected' })] }) - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([orphanDir]) - - // A later stop is a real retry, not a no-op. - await tearDownStructuredAgentSessionHost({ phases: [], sessions: new Map() }) - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(orphanDir) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts new file mode 100644 index 00000000000..8833ac4ee31 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -0,0 +1,135 @@ +// What a Stop's row says when Codex took the interrupt, against the real host, journal and Codex +// adapter. Codex answers a turn's interrupt as the turn aborts and sends the turn's end right after +// the answer, so the end reaches Orca a moment after the Stop has its answer. + +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + THREAD_ID as THREAD, + adapterFor, + fakeCodex +} from '../../codex/codex-structured-session-adapter-fixture' +import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let turns: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) + resetHostTestOperationIds() + const codex = fakeCodex() + const notify = (method: string, params: unknown): void => + codex.connections.at(-1)?.handlers.onNotification?.(method, params) + turns = codexTurnLifecycleFake(THREAD, () => notify) + codex.routes['turn/start'] = turns.routes['turn/start'] + codex.routes['turn/interrupt'] = () => { + const turnId = turns.turnId + // The answer first, then the turn's end on a later read of Codex's output. + setTimeout( + () => + notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), + 4 + ) + return {} + } + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapterFor(codex), { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function runningTurn(): Promise { + expect(await send('count to 40')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) + turns.start() + await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['running'])) +} + +async function journalRows() { + const items = (await host.journalSnapshot(SESSION)).items + return { + statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), + turns: items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.state] : [])) + } +} + +describe('a Codex Stop that Codex answered', () => { + it.each([ + ['names no turn', undefined], + ['names its turn, as an older client sends it', 'turn-1'] + ] as const)( + 'reads as requested, with the turn interrupted, when it %s', + async (_case, turnId) => { + await runningTurn() + + const stopped = await stop(turnId) + await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['interrupted'])) + await host.flushStreamedEvents(SESSION) + + expect((await journalRows()).statuses).toEqual(['Cancellation requested.']) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + } + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index b9b7021e854..a001eddb3f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -11,7 +11,7 @@ import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession } from '../../../shared/structured-agent-session-reducer' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { adapterFor, @@ -60,7 +60,7 @@ it('delivers catalog changes through existing frames without resending them on o state = reduceStructuredAgentSession(state, { type: 'event', event }) }) try { - const journal = await journals.open({ identity: identityFor(), journalDir: root }) + const journal = await journals.open({ identity: identityFor(), stateDirectory: root }) const sessionId = identityFor().sessionId let commands: AgentSessionSlashCommand[] | undefined = [ { name: 'loaded', kind: 'command', kindUnspecified: true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts new file mode 100644 index 00000000000..52ac80ff7fc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -0,0 +1,192 @@ +// After `/compact`, every reader that reports on the user's requests reads past the command: the +// sidebar's prompt, preview, verdict and instant, restart resume, and an older client's label. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { latestStructuredAgentSessionRequest } from '../../../shared/structured-agent-session-latest-request' +import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectTurnItemHistory } from '../../runtime/rpc/methods/structured-agent-session-turn-item-capability' +import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' +import { + childRecord, + journal, + NOW, + record, + SESSION, + TEARDOWN_CURRENT +} from './structured-agent-session-restart-resume-test-harness' + +const PROMPT = agentJournalSubmissionKey('prompt-1') +const COMMAND = agentJournalSubmissionKey('command-1') +const REAL_TURN = 'legacy:codex:session:turn-lifecycle%3Aturn-1' +const COMMAND_TURN = 'orca:command-turn%3Acommand-1' + +let sequence = 0 +function item(itemId: string, body: AgentJournalRenderItem['body'], scope?: string) { + sequence += 1 + return { + itemId, + revision: 1, + body, + sequence, + observedAt: NOW, + turnScope: scope ? { kind: 'turn' as const, turnItemId: scope } : { kind: 'thread' as const } + } +} + +function accepted(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: NOW + } +} + +/** "List three fruits", answered, then `/compact` in its own turn. */ +function compactedAfterARealTurn(commandState: 'running' | 'completed' = 'completed') { + sequence = 0 + const items: AgentJournalRenderItem[] = [ + item(PROMPT, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + item(REAL_TURN, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + outcome: 'success', + userItemId: PROMPT, + startedAt: NOW, + completedAt: NOW + 1_000 + }), + item( + 'codex:answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Apple, pear, fig' }] }, + REAL_TURN + ), + item(COMMAND, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(COMMAND_TURN, { + kind: 'turn', + turnId: 'compact:command-1', + state: commandState, + userItemId: COMMAND, + startedAt: NOW + 5_000, + ...(commandState === 'completed' ? { outcome: 'success', completedAt: NOW + 9_000 } : {}) + }), + item( + 'codex:summary', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Summary of it all' }] }, + COMMAND_TURN + ), + item( + 'orca:command-result%3Acommand-1', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + COMMAND_TURN + ) + ] + return { items, submissions: [accepted('prompt-1'), accepted('command-1')] } +} + +describe('the sidebar after /compact (B7)', () => { + it('keeps the last real prompt, its answer, its verdict and its instant', () => { + const { items, submissions } = compactedAfterARealTurn() + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toEqual({ + status: 'idle', + latestPrompt: 'List three fruits', + lastAssistantMessage: 'Apple, pear, fig', + turnOutcome: 'success', + statusStartedAt: NOW + 1_000 + }) + }) + + it('reads working while the command runs', () => { + const { items, submissions } = compactedAfterARealTurn('running') + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({ + status: 'working', + latestPrompt: 'List three fruits' + }) + }) +}) + +describe('the completion feed around /compact (B6)', () => { + // The feed announces on a change of latest request; /compact running or settled leaves it be. + it.each(['running', 'completed'] as const)( + 'keeps the last real turn latest while it is %s', + (state) => { + const { items, submissions } = compactedAfterARealTurn(state) + expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ + kind: 'turn', + id: 'turn-1', + running: false, + outcome: 'success' + }) + } + ) +}) + +describe('restart resume around /compact (B14)', () => { + function markerFor(items: AgentJournalRenderItem[], tasks: boolean) { + return structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal(items), child: { fence: 1 } }, + getRecord: () => record(), + childWork: () => + tasks ? [childRecord({ id: 'task-a', kind: 'agent', description: 'Review' })] : [], + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + } + + it('records nothing while the command runs', () => { + expect(markerFor(compactedAfterARealTurn('running').items, false)).toBeNull() + }) + + it('anchors a settled lead with live children on its last real turn', () => { + const marker = markerFor(compactedAfterARealTurn().items, true) + expect(marker?.work).toEqual({ kind: 'turn', id: 'turn-1' }) + // The newest user message is still the last real prompt, as before commands were messages. + expect(marker?.latestUserItemId).toBe(PROMPT) + }) +}) + +describe("an older client's label for a command turn (B17)", () => { + it("names the session's agent, not the one its key suggests", () => { + const { items, submissions } = compactedAfterARealTurn() + const history = { + ok: true as const, + page: { + sessionId: SESSION, + epoch: 'epoch-1', + direction: 'tail' as const, + items, + removedItemIds: [], + submissions, + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + } + const projected = projectTurnItemHistory(history, { clientKind: 'runtime' }, 'claude') + const commandTurn = projected.page.items.find((entry) => entry.itemId === COMMAND_TURN) + expect(commandTurn?.body).toMatchObject({ + kind: 'status', + text: expect.stringContaining('Claude') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts new file mode 100644 index 00000000000..25b6addb3c5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -0,0 +1,323 @@ +// A conversation command the user sent, such as `/compact`, carried out as a turn of its own. +// +// The command is an ordinary queued message until the delivery loop hands it over. There the loop +// opens the command's turn and sends it; the provider's receipt resolves the message, as it does +// any send. The provider child's journal translator ends the turn from the provider's own frames, +// and a child that ends first is settled with it. The host writes a command's end only when the +// provider never took it. While the turn runs it takes no input, so the loop hands nothing over. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionAdapter, + StructuredAgentSessionProviderChildPhase +} from './structured-agent-session-adapter' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' + +/** What the user sent for `/compact`: the text they typed, and the command it names. */ +export function structuredAgentSessionCompactBody(): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND } + } +} + +type AwaitedSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'acceptedSequence' +> + +/** Optional `submissions` as the dead-generation journal reads it. */ +export type StructuredAgentSessionAwaitedCommandJournal = { + submissions?: () => readonly AwaitedSubmission[] + itemBody: AgentSessionJournal['itemBody'] +} + +/** The command the oldest message still waiting on the provider names: a start that fails now + * fails that message first, so its next step is to run the command again. */ +export function structuredAgentSessionAwaitedCommand( + journal: StructuredAgentSessionAwaitedCommandJournal +): AgentSessionConversationCommand | undefined { + let oldest: AwaitedSubmission | undefined + for (const submission of journal.submissions?.() ?? []) { + if ( + submission.dispatchState === 'pending' && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + const body = oldest && journal.itemBody(agentJournalSubmissionKey(oldest.clientMessageId)) + return body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + : undefined +} + +/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the + * host's Stop and delivery gate tell a command's turn from any other. */ +export function structuredAgentSessionCommandTurn(clientMessageId: string): { + identity: AgentJournalItemIdentity + itemId: string + turnId: string + /** The command's one result row, inside its turn. */ + resultIdentity: AgentJournalItemIdentity +} { + const identity = { provider: 'orca' as const, clientMessageId: `command-turn:${clientMessageId}` } + return { + identity, + itemId: agentJournalItemKey(identity), + turnId: `compact:${clientMessageId}`, + resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } + } +} + +/** Whether the journal's running turn is a command's, which takes no input while it runs. */ +export function structuredAgentSessionCommandRunning( + journal: Pick +): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + +export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { + return turnId.startsWith('compact:') +} + +const STOP_NOTE_PREFIX = 'stop:' + +/** A Stop's note, on the turn it named. The key says what it is, so a later Stop can read it. */ +export function structuredAgentSessionStopNoteIdentity( + clientOperationId: string +): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${clientOperationId}` } +} + +/** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the + * journal, so nothing is held that could outlive the command. */ +export function structuredAgentSessionCommandWasStopped( + journal: Pick, + turnId: string +): boolean { + const { itemId } = structuredAgentSessionCommandTurn(turnId.slice('compact:'.length)) + return journal.snapshot().items.some((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return ( + item.turnScope?.kind === 'turn' && + item.turnScope.turnItemId === itemId && + identity?.provider === 'orca' && + identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) + ) + }) +} + +export type StructuredAgentSessionCommandHandoverContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a failure the handover meets names, as the start's own row does. */ + failureTextContext?: AgentSessionFailureWordsContext + record: () => AgentSessionRecord | null + /** The session's child records, the same read the strip and conversation-command admission use. */ + childWork: () => readonly AgentChildWorkView[] | undefined + flushStreamedEvents: () => Promise + now: () => number +} + +/** Refuses the command, or opens its turn and sends it. */ +export async function handOverStructuredAgentSessionCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + submission: AgentJournalSubmission, + body: AgentJournalMessageItem +): Promise { + const { clientMessageId } = submission + // Provider frames already received decide whether a turn is running. + await ctx.flushStreamedEvents() + const blocked = commandBlocked(ctx, body) + if (blocked) { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), + fence: ctx.fence + }) + return + } + const turn = structuredAgentSessionCommandTurn(clientMessageId) + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + const startedAt = ctx.now() + const running = agentJournalTurnBody({ + turnId: turn.turnId, + state: 'running', + userItemId: agentJournalSubmissionKey(clientMessageId), + requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission), + startedAt + }) + await ctx.journal.appendItem(turn.identity, running, { + fence: ctx.fence, + observedAt: startedAt, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let admission: AgentSessionCommandAdmission + try { + admission = await ctx.adapter.compact!({ + sessionId: ctx.sessionId, + fence: ctx.fence, + command: { clientMessageId, ...turn, running } + }) + } catch (error) { + // A child that had not proven its start took nothing, so the command provably did not run. Any + // other throw is a lost reply: the command may have run. + const unsent = + ctx.providerChildPhase?.() === 'starting' + ? { + state: 'rejected' as const, + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } + : { + state: 'unknown' as const, + reason: error instanceof Error ? error.message : String(error) + } + await settleUnsentCommand(ctx, clientMessageId, unsent) + return + } + if (admission.state === 'rejected') { + // The provider refused the compaction itself: its row reads as the compaction failing. + await settleUnsentCommand( + ctx, + clientMessageId, + admission, + agentSessionFailureFact('compactionFailed', { detail: admission.rejection.detail }) + ) + } else if (admission.state !== 'admitted') { + // An unknown write leaves the turn to the provider's end or the child's: it may have run. + await ctx.journal.resolveDispatch({ clientMessageId, ...admission, fence: ctx.fence }) + } +} + +/** Where the turn a handed-over submission runs in starts counting: its handover, so time spent + * held behind a command or a start is not counted as the agent's work. */ +export function structuredAgentSessionHandoverOrigin( + journal: AgentSessionJournal, + submission: AgentJournalSubmission +): number { + const handedOver = journal + .submissions() + .find((entry) => entry.clientMessageId === submission.clientMessageId) + return handedOver?.handedOverAt ?? submission.submittedAt +} + +/** The command's end when the provider never took it: refused, or lost with the adapter's throw. + * The message's answer goes first, so a crash before the turn's end leaves a running turn, which + * the stale-turn sweep settles, never an ended turn whose message still reads as in flight. */ +async function settleUnsentCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + clientMessageId: string, + unsent: + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string }, + /** What the result row reports, when it is not the rejection's own fact. */ + rowFailure?: AgentSessionFailureFact +): Promise { + const turn = structuredAgentSessionCommandTurn(clientMessageId) + const running = readAgentJournalTurn(ctx.journal.itemBody(turn.itemId) ?? undefined) + await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) + if (running?.state !== 'running') { + return + } + const refused = unsent.state === 'rejected' + const mutations: JournalLifecycleMutationInput[] = [ + ...(refused + ? [ + { + kind: 'item' as const, + identity: turn.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { + ...ctx.failureTextContext, + surface: 'row' + }), + tone: 'error' as const + }, + turnScope: { kind: 'turn' as const, turnItemId: turn.itemId } + } + ] + : []), + { + kind: 'item', + identity: turn.identity, + body: agentJournalTurnBody({ + ...running, + ...(refused + ? { state: 'completed', outcome: 'failure', completedAt: ctx.now() } + : { state: 'unverifiable' }) + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + await ctx.journal.appendLifecycleBatch({ + settlementId: `command-settled:${clientMessageId}`, + fence: ctx.fence, + mutations + }) +} + +/** Why the command may not run now, as the fact its message is rejected with; null when it may. */ +function commandBlocked( + ctx: StructuredAgentSessionCommandHandoverContext, + body: AgentJournalMessageItem +): SubmissionRejectionFact | null { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + return agentSessionFailureFact('commandRefused') + } + const record = ctx.record() + if (!record) { + return agentSessionFailureFact('hostFault') + } + const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') + return refusal + ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) + : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index cae2a8c3954..a7f70ab665d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -44,6 +44,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { const { sessions, serialize } = host const deps = () => host.context().deps const readRefusals = createJournalOpenReadRefusals() + // The owed copy fails as an open does: the reader gets the classified refusal, never its text. + const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => + session.journal.whenImported().catch((error: unknown) => { + throw readRefusals.refusal(sessionId, error) + }) // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. const stopAgent = (sessionId: string) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) @@ -101,6 +106,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { conversation: async (sessionId: string): Promise => { const open = sessions.get(sessionId) if (open) { + // Restore left its per-chat file uncopied; a reader gets the chat from the one database. + // Awaited only then: an open conversation otherwise answers in the same turn. + if (open.journal.importPending) { + await whenImported(sessionId, open) + } readRefusals.forget(sessionId) return open } @@ -128,6 +138,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } + // Restore may have opened it while this waited. + if (session.journal.importPending) { + await whenImported(sessionId, session) + } readRefusals.forget(sessionId) return session }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index dd8853954e3..ec938f17f13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -9,7 +9,7 @@ // wakes. Nothing here starts a provider child. import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -37,13 +37,17 @@ export type OpenedStructuredAgentSessionConversation = { export type StructuredAgentSessionConversationOpenDeps = { store: Pick adapter: Pick - journalRoot: string + journalDatabase: JournalHostDatabase onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] } /** An acquisition's own open: its reserve cleared the record's death evidence, so it settles * what the gone generation left running itself, from what it read before. */ -export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean } +export type StructuredAgentSessionConversationOpenOptions = { + acquisition?: boolean + /** A restore's open, which copies no per-chat file: see `AgentSessionJournal.whenImported`. */ + deferPerSessionImport?: boolean +} export type StructuredAgentSessionConversationOpenContext = { deps: StructuredAgentSessionConversationOpenDeps @@ -90,12 +94,10 @@ export async function openStructuredAgentSessionConversationJournal( const identity = journalIdentityFor(record, params) const opened = await openAgentSessionJournalWithRecovery({ identity, - journalDir: journalDirectoryFor(deps.journalRoot, { - workspaceId: record.location.workspaceId, - sessionId - }), + database: deps.journalDatabase, fence, - historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null + historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null, + deferPerSessionImport: options.deferPerSessionImport }) try { // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts new file mode 100644 index 00000000000..2eeae4787ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -0,0 +1,367 @@ +// A Stop that names no turn stops what the conversation has in flight: it withdraws what is +// queued, and interrupts a handed-over message even before the provider has opened its turn — +// the gap no client can name a turn for. Against the real host, store and journal. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { CancelParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const CALLER = { callerKey: 'client-1' } +const ALREADY_FINISHED = 'The provider had already finished this turn.' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let cancelTurn: Mock +let awaitStarted: Mock> +let events: StructuredAgentSessionEventSink | undefined + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) + resetHostTestOperationIds() + // Admitted, not accepted: the message is written and its turn has not opened. + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + awaitStarted = vi.fn(async () => undefined) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken, events: sink }) => { + events = sink + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const result = host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + return { id: clientOperationId, result } +} + +function stop(turnId?: string, clientOperationId = hostTestOperationId()) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +describe('a Stop that names no turn', () => { + it('is a valid cancel, and only a plain Stop may omit the turn', () => { + const envelope = { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: '0'.repeat(64) + } + expect(CancelParams.safeParse({ envelope }).success).toBe(true) + expect( + CancelParams.safeParse({ envelope, prompt: { itemId: 'item-1', expectedRevision: 1 } }) + .success + ).toBe(false) + expect(CancelParams.safeParse({ envelope, scope: 'background-tasks' }).success).toBe(false) + }) + + it('interrupts a handed-over message before its turn opens, as a cancellation', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + expect( + (await host.journalSnapshot(SESSION)).items.some((item) => item.body.kind === 'turn') + ).toBe(false) + + const stopped = await stop() + + expect(stopped).toEqual({ + ok: true, + replayed: false, + fence: 1, + cursor: expect.anything(), + value: { cancelled: true } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') + expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { + const started = Promise.withResolvers() + awaitStarted.mockImplementationOnce(() => started.promise) + const { id, result } = send('hello') + await result + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + started.resolve(undefined) + + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(cancelTurn).not.toHaveBeenCalled() + await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) + + it('withdraws a send still on its way in, which the host takes first', async () => { + const { id, result } = send('hello') + const stopped = stop() + + expect(await result).toMatchObject({ ok: true }) + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('says the agent did not stop, in its words, when it refused', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(cancelTurn).toHaveBeenCalledOnce() + expect(await statusRows()).toEqual(["Codex didn't stop: no active turn to interrupt."]) + }) + + it('says the Stop is unconfirmed, not that nothing ran, when the provider never answered it', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + // Codex answers an interrupt as the turn ends, so a turn that never ends leaves it unanswered. + cancelTurn.mockRejectedValueOnce(new Error('codex app-server turn/interrupt exceeded 30000ms')) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) + }) + + it('says the agent had no turn to stop when it had none', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await statusRows()).toEqual(['Codex had no turn running to stop.']) + }) + + it('stops nothing when it reuses the id of a Stop the host already ran', async () => { + const operationId = hostTestOperationId() + expect(await stop(undefined, operationId)).toMatchObject({ ok: true, replayed: false }) + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + + // Why the client never reuses a no-turn Stop's id: the same id is the same Stop. + expect(await stop(undefined, operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(cancelTurn).toHaveBeenCalledOnce() + }) + + // Claude's echo accepts the send one sink write before the row of the turn it opens. + async function acceptedWithTurnRowUnlanded(): Promise { + dispatch.mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 } + }) + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + } + + it('interrupts a turn whose accepted send is in the journal before its row lands', async () => { + await acceptedWithTurnRowUnlanded() + const drain = host.flushStreamedEvents + vi.spyOn(host, 'flushStreamedEvents').mockImplementation((sessionId) => { + events!.appendItem( + { provider: 'legacy', agent: 'codex', sessionId, recordId: 'turn-lifecycle:turn-2' }, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'turn-2', state: 'running' } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return drain(sessionId) + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(cancelTurn).toHaveBeenCalledOnce() + expect(await statusRows()).toContain('Cancellation requested.') + }) + + it('still interrupts when draining the streamed rows fails', async () => { + await acceptedWithTurnRowUnlanded() + vi.spyOn(host, 'flushStreamedEvents').mockRejectedValueOnce(new Error('sink barrier failed')) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(cancelTurn).toHaveBeenCalledOnce() + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + it('is a quiet no-op with nothing in flight', async () => { + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) +}) + +describe('a Stop that names its turn, as an older client sends it', () => { + it('reaches the provider with that turn and keeps its not-cancelled note', async () => { + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop('turn-1')).toMatchObject({ + ok: true, + value: { turnId: 'turn-1', cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId: 'turn-1' })) + expect(await statusRows()).toEqual([ALREADY_FINISHED]) + }) + + async function queueOnHost(): Promise<{ id: string; release: () => void }> { + const started = Promise.withResolvers() + awaitStarted.mockImplementationOnce(() => started.promise) + const { id, result } = send('hello') + await result + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + return { id, release: () => started.resolve(undefined) } + } + + it('reports success with no row when it withdrew a queued message and the turn had ended', async () => { + const queued = await queueOnHost() + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + queued.release() + + expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) + expect(await statusRows()).toEqual([]) + }) + + // Codex refuses an interrupt for a turn that has ended. + it('reports success with no row when the provider refused a turn that had ended', async () => { + const queued = await queueOnHost() + cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no such turn', audience: 'person' } } + }) + + expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + queued.release() + + expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) + expect(await statusRows()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index b294540990d..3557594b751 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -1,10 +1,11 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' @@ -30,7 +31,7 @@ afterEach(async () => { async function openJournal(name: string): Promise { root ??= await mkdtemp(join(tmpdir(), 'orca-conversations-')) - return journals.open({ identity: IDENTITY, journalDir: join(root, name) }) + return journals.open({ identity: IDENTITY, stateDirectory: join(root, name) }) } function session(journal: AgentSessionJournal) { @@ -45,7 +46,7 @@ function appendStatus(journal: AgentSessionJournal, text: string) { return journal.appendItem( { provider: 'orca', clientMessageId: text }, { kind: 'status', text }, - { fence: 0 } + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 6fcaef07483..c312d6013fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -22,6 +22,8 @@ export class StructuredAgentSessionConversations extends Map< private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void onDeliveryError: (sessionId: string, error: unknown) => void + /** A conversation became held: state that waited on it (queued drafts) re-derives. */ + onOpened?: (sessionId: string) => void now: () => number } ) { @@ -49,7 +51,9 @@ export class StructuredAgentSessionConversations extends Map< }) }) this.activity.set(sessionId, this.delivery.now()) - return super.set(sessionId, session) + const adopted = super.set(sessionId, session) + this.delivery.onOpened?.(sessionId) + return adopted } override delete(sessionId: string): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 6e364ccb477..22295ea6e86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -13,7 +13,8 @@ import { import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { openCodexAppServerConnection } from '../../codex/codex-app-server-connection' import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -23,6 +24,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -57,10 +59,7 @@ async function crash(dying: AgentSessionRecordStore): Promise { } function openStore(generation: HostGeneration): Promise { - return AgentSessionRecordStore.open({ - directory: join(generationRoot(generation), 'store'), - hostId: 'local' - }) + return openTestAgentSessionRecordStore(generationRoot(generation)) } /** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then @@ -95,7 +94,7 @@ function host( return new StructuredAgentSessionHost({ store, adapter, - journalRoot: generationRoot(generation), + journalDatabase: openTestJournalHostDatabase(generationRoot(generation)), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 0d83491ee21..c7f8f09631a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -4,21 +4,29 @@ // that runs for half a minute leaves one journal row at its start. The lease renewal the host // wrote every ten seconds is what saw the child working after that row. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { completedStructuredAgentTurnSeconds, selectStructuredAgentTurnTimings } from '../../../shared/structured-agent-session-turn-timing' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { AgentSessionAcquisitionExitUnprovenError, @@ -91,21 +99,8 @@ function crashedClaudeRecord(): AgentSessionRecord { } async function seedCrashedStore(): Promise { - const directory = join(root, 'store') - await mkdir(directory, { recursive: true }) - await writeFile( - join(directory, AGENT_SESSION_STORE_FILE_NAME), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: crashedClaudeRecord() }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }), - 'utf-8' - ) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await seedTestAgentSessionRecordStore(root, { records: [crashedClaudeRecord()] }) + store = await openTestAgentSessionRecordStore(root) } /** A running turn whose only row after its start is a Bash call that never reported back, for a @@ -120,10 +115,7 @@ async function seedClaudeToolTurn(): Promise { agent: 'claude', providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } }, - journalDir: journalDirectoryFor(root, { - workspaceId: LOCATION.workspaceId, - sessionId: SESSION - }), + database: openTestJournalHostDatabase(root), now: () => now }) await journal.appendSubmission({ @@ -132,10 +124,16 @@ async function seedClaudeToolTurn(): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, fence: 13 }) + const turnIdentity = { + provider: 'claude' as const, + sessionId: PROVIDER_SESSION, + uuid: 'uuid-turn' + } + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(turnIdentity) } await journal.appendItem( - { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' }, + turnIdentity, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: now }, - { fence: 13 } + { fence: 13, turnScope } ) now = TOOL_STARTED_AT await journal.appendItem( @@ -146,7 +144,7 @@ async function seedClaudeToolTurn(): Promise { input: { command: 'for i in $(seq 90); do sleep 1; done' }, state: 'running' }, - { fence: 13 } + { fence: 13, turnScope } ) await journal.close() } @@ -162,7 +160,7 @@ function openHost(overrides: Partial): void { setOption: vi.fn(), supportsCreate: () => true }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', now: () => RELAUNCHED_AT, @@ -185,6 +183,7 @@ beforeEach(async () => { afterEach(async () => { await host?.flushAllStreamedEvents() + closeTestJournalHostDatabases() await rm(root, { recursive: true, force: true }) }) @@ -363,7 +362,8 @@ describe('a turn a read reached before the reconcile proved its owner dead', () // The new child is already working when its start lands, ahead of the queued revision. events?.appendItem( { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn-2' }, - { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT } + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { process, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 4a0febb6ceb..6677a50b447 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -15,6 +16,7 @@ import { settleStructuredAgentSessionDeadGeneration, unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' @@ -34,7 +36,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) }) @@ -54,7 +56,7 @@ async function seedUnfinishedWork(): Promise { await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, @@ -65,7 +67,7 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 3 }, @@ -75,12 +77,12 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'web', label: 'Web' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 4 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -198,7 +200,8 @@ describe('dead structured-session generation settlement', () => { { kind: 'status', text: "Orca ran into a problem, so this didn't go through. Try again.", - failure: { kind: 'hostFault' } + failure: { kind: 'hostFault' }, + tone: 'error' } ]) }) @@ -349,7 +352,7 @@ describe('dead structured-session generation settlement', () => { await journal.appendItem( childCall, { kind: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'running' }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( childAsk, @@ -360,7 +363,7 @@ describe('dead structured-session generation settlement', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await settleStructuredAgentSessionDeadGeneration({ @@ -402,7 +405,7 @@ describe('dead structured-session generation settlement', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) expect(settledProducers()).toEqual(settled) @@ -420,12 +423,12 @@ describe('whether a dead generation interrupted anything', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, { kind: 'turn', turnId: 'turn-1', state: 'completed', startedAt: 900, completedAt: 950 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 8ee3430c5c9..72caac40844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -5,9 +5,10 @@ import { } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { - AgentJournalItemBody, - AgentJournalRenderItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' @@ -30,6 +31,10 @@ import { turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + exitedRootTurnScope, + runningRootTurnScope +} from './structured-agent-session-exit-turn-scope' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ @@ -144,6 +149,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) } } else if (showUnexpectedExitOutcome) { + // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: input.settlementId }, @@ -155,15 +161,22 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ...input.failureTextContext, surface: 'row' } - ) - } + ), + tone: 'error' + }, + turnScope: exitedRootTurnScope(items, input.verdict) }) } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) @@ -212,7 +225,12 @@ export async function settleStaleStructuredAgentSessionState(input: { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) @@ -240,8 +258,10 @@ export async function settleStaleStructuredAgentSessionState(input: { ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { ...input.failureTextContext, surface: 'row' - }) - } + }), + tone: 'error' + }, + turnScope: runningRootTurnScope(items) }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 7dd17306105..8ea4092113b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -2,13 +2,16 @@ // it, and the one thing that settles a queued message because of a start, a child or a leftover. // // A send is accepted on its own serialized step and returns; this loop does the rest. It exists -// for a session exactly while a message is queued there — accepted, not yet handed over — and -// every step re-reads the journal and the conversation's child record to decide, so there is no -// loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop +// for a session exactly while a message is queued there — accepted, not yet handed over — and no +// child is running a conversation command: a command's turn takes no input, and the commit that +// ends it wakes the loop again. Every step re-reads the journal and the conversation's child +// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop // that arrives while a start holds the queue withdraw the queued messages before the handover that // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact, type SubmissionRejectionFact @@ -35,6 +38,7 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -53,6 +57,10 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void + record: (sessionId: string) => AgentSessionRecord | null + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + flushStreamedEvents: (sessionId: string) => Promise + now: () => number } type Step = 'continue' | 'stop' @@ -144,7 +152,9 @@ export class StructuredAgentSessionDeliveryLoop { (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) const oldest = oldestQueuedSubmission(session) - if (!oldest) { + // A running command takes no input while its child carries it; its end is a commit, which + // wakes the loop again. With no child it is a gone generation's, which the start below settles. + if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } const failedStart = startThatFailedWhileQueued(session, oldest) @@ -208,7 +218,11 @@ export class StructuredAgentSessionDeliveryLoop { fence: awaitedChild.fence, adapter: this.deps.adapter, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, - failureTextContext: this.deps.failureTextContext(sessionId) + failureTextContext: this.deps.failureTextContext(sessionId), + record: () => this.deps.record(sessionId), + childWork: () => this.deps.readChildWork(sessionId), + flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), + now: this.deps.now }, next ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 428a3c1c0d4..2d0766a4bce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -1,6 +1,7 @@ import type { StructuredAgentSessionAppendOptions, StructuredAgentSessionEventTarget, + StructuredAgentSessionLinkageJournal, StructuredAgentSessionReadingControl, StructuredAgentSessionSinkAdmission, StructuredAgentSessionSinkBarrier, @@ -64,6 +65,8 @@ export class StructuredAgentSessionSinkQueue { journalEpoch = (): string | null => this.target?.journal.epoch ?? null + journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control if (this.backpressured) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index b8c1d0b7c0e..46dc8fbf7e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -37,7 +38,7 @@ type Recorded = { * it: a double that omits the third parameter makes every assertion about what * the sink forwards pass against `undefined`, which is how this went unnoticed * before. Kept separate so the call-order assertions stay about call order. */ -const journalAppendOptions: JournalItemAppendOptions[] = [] +const journalAppendOptions: Partial[] = [] function target( fence: number, @@ -92,8 +93,8 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.publish() expect(log).toEqual([]) @@ -112,10 +113,10 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(1, log)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) // The re-attach that raised the fence. deferred.bind(target(2, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([ @@ -130,7 +131,7 @@ describe('deferred structured agent-session event sink', () => { deferred.bind(target(1, log)) deferred.unbind() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) expect(log).toEqual([]) deferred.bind(target(2, log)) await deferred.drained() @@ -143,9 +144,15 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() expect( - deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1)) + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null)).toEqual({ + expect( + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) deferred.bind(target(2, log)) @@ -161,10 +168,10 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.close() deferred.bind(target(3, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([]) @@ -180,7 +187,7 @@ describe('deferred structured agent-session event sink', () => { }) deferred.bind(target(4, log, 0)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendTombstone(identity(1)) const barrier = await deferred.drained() @@ -202,14 +209,14 @@ describe('deferred structured agent-session event sink', () => { const runtime = new StructuredAgentSessionHostRuntimeState({ store: {} } as never) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) - failed.sink.appendItem(identity(0), BODY) + failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(failed.drained()).resolves.toMatchObject({ ok: false }) const recovered = runtime.eventSinkFor('session-1') expect(recovered).not.toBe(failed) const log: Recorded[] = [] recovered.bind(target(2, log)) - recovered.sink.appendItem(identity(1), BODY) + recovered.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(recovered.drained()).resolves.toEqual({ ok: true }) expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 1 }]) }) @@ -229,9 +236,15 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(1), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -257,11 +270,17 @@ describe('deferred structured agent-session event sink', () => { } }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) expect( - deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1)) + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -292,7 +311,9 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 1 }) expect(readingControl.pauseReading).toHaveBeenCalledOnce() @@ -322,7 +343,7 @@ describe('deferred structured agent-session event sink', () => { deferred.sink.appendLifecycleBatch?.( 'settlement-1', - [{ kind: 'item', identity: identity(0), body: BODY }], + [{ kind: 'item', identity: identity(0), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { lifecycle: true } ) expect(deferred.sink.tryPublish?.({ lifecycle: true })).toEqual({ @@ -354,7 +375,9 @@ describe('deferred structured agent-session event sink', () => { }) const releaseFirst = deferred.sink.bindReadingControl?.(firstControl) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(firstControl.pauseReading).toHaveBeenCalledOnce() const releaseSecond = deferred.sink.bindReadingControl?.(secondControl) @@ -373,7 +396,7 @@ describe('deferred structured agent-session event sink', () => { it('replaces a queued same-item checkpoint before it runs', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) deferred.sink.appendItem(identity(1), BODY, options) @@ -390,7 +413,12 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() const batch = (ordinal: number) => [ - { kind: 'item' as const, identity: identity(ordinal), body: BODY } + { + kind: 'item' as const, + identity: identity(ordinal), + body: BODY, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] deferred.sink.appendLifecycleBatch?.('turn-completed:turn-1', batch(0)) @@ -413,10 +441,10 @@ describe('deferred structured agent-session event sink', () => { it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendItem(identity(2), BODY, options) deferred.bind(target(6, log)) await deferred.drained() @@ -460,10 +488,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -479,10 +512,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, () => identity(1), { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, () => identity(1), { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -496,7 +534,7 @@ describe('producer linkage reaches the journal through every append path', () => deferred.bind(target(5, log)) deferred.sink.appendLifecycleBatch?.( 'settle-1', - [{ kind: 'item', identity: identity(1), body: BODY }], + [{ kind: 'item', identity: identity(1), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { ...LINKAGE } ) @@ -511,13 +549,13 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() // A control, not a pin. Absence is the claim, so the keys must be missing // rather than present-and-undefined: a reader holding this options object // would read `agentId: undefined` as a key that exists. - expect(journalAppendOptions).toEqual([{ fence: 5 }]) + expect(journalAppendOptions).toEqual([{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }]) deferred.close() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index ad92e1c24ca..121fb865db4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -2,7 +2,8 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -36,6 +37,11 @@ export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & observedAt?: number } +/** An item write states which turn its row belongs to; the write that creates the row decides. */ +export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { + turnScope: AgentJournalTurnScope +} + export type StructuredAgentSessionLifecycleJournal = Pick< AgentSessionJournal, 'epoch' | 'visitItems' @@ -51,6 +57,12 @@ export type StructuredAgentSessionRevisionJournal = Pick< 'epoch' | 'visitItems' | 'itemBody' > +/** Rows already journaled and who produced each, read by a producer that has to agree with them. */ +export type StructuredAgentSessionLinkageJournal = Pick< + AgentSessionJournal, + 'epoch' | 'visitItemsWithLinkage' +> + /** The row a revision rewrites and its whole new body, read from the journal at execution. */ export type StructuredAgentSessionRevisionResolver = ( journal: StructuredAgentSessionRevisionJournal @@ -58,7 +70,7 @@ export type StructuredAgentSessionRevisionResolver = ( /** A revision's body is derived from the row it revises, so coalescing one away would lose it. */ export type StructuredAgentSessionRevisionOptions = Omit< - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, 'coalescingKey' > @@ -69,7 +81,7 @@ export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): void appendTombstone( identity: AgentJournalItemIdentity, @@ -84,43 +96,45 @@ export type StructuredAgentSessionEventSink = { tryAppendItem?( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues an ordinary append whose identity is resolved after journal bind. */ tryAppendResolvedItem?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues one resolved append and its publication as a single admitted operation. */ tryAppendResolvedItemAndPublish?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues a read-modify-write of one row; `reservedBytes` must bound the resolved write. */ tryReviseResolvedItem?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one revision and its publication as a single admitted operation. */ tryReviseResolvedItemAndPublish?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ tryAppendLifecycleTransition?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Current durable epoch, when this deferred sink is bound to its journal. */ journalEpoch?(): string | null + /** The bound journal's producer linkage; null until bound. */ + journalLinkage?(): StructuredAgentSessionLinkageJournal | null appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -231,7 +245,7 @@ export function createDeferredStructuredAgentSessionEventSink( return { sink: { - appendItem: (identity, body, options = {}) => { + appendItem: (identity, body, options) => { queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), @@ -246,7 +260,7 @@ export function createDeferredStructuredAgentSessionEventSink( options ) }, - tryAppendItem: (identity, body, options = {}) => + tryAppendItem: (identity, body, options) => queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), @@ -262,6 +276,7 @@ export function createDeferredStructuredAgentSessionEventSink( ), ...resolvedAppend, journalEpoch: queue.journalEpoch, + journalLinkage: queue.journalLinkage, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts new file mode 100644 index 00000000000..c336a1d3eca --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts @@ -0,0 +1,36 @@ +// Which turn a gone generation's exit row belongs to, so the row reports on the turn it ended. + +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' + +/** The turn the exit ended: still running, or already ended at the exit's instant by the child's + * own translator, which settles its open turn when the child goes. */ +export function exitedRootTurnScope( + items: readonly AgentJournalRenderItem[], + verdict: StructuredAgentSessionTurnVerdict +): AgentJournalTurnScope { + const endedAt = verdict.state === 'interrupted' ? verdict.completedAt : undefined + const ended = items.findLast((item) => { + const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item.body) : null + return ( + turn?.state === 'running' || + (turn?.state === 'interrupted' && endedAt !== undefined && turn.completedAt === endedAt) + ) + }) + return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} + +export function runningRootTurnScope( + items: readonly AgentJournalRenderItem[] +): AgentJournalTurnScope { + const running = items.findLast( + (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' + ) + return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 317e7ca81a0..d1a45fc469a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -5,7 +5,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, @@ -21,6 +22,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -47,7 +49,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -58,7 +60,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 69f9b95c03a..06538d78310 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -6,7 +6,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionPreSpawnError, type StructuredAgentSessionAdapter @@ -19,6 +20,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -43,7 +45,7 @@ beforeEach(async () => { }, acquisitionGeneration: `generation-${fence}` })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -54,7 +56,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index fcebdda7726..ccef42f57bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -114,7 +114,6 @@ describe('structuredAgentSessionStartFailure', () => { }) it("holds any provider detail to the lease record's cap", () => { - expect(MAX_PROVIDER_DIAGNOSTIC_CHARS).toBe(512) expect(MAX_UNEXPECTED_EXIT_REASON_CHARS).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) const long = 'x'.repeat(4_000) // However the caller built the detail, the fact stores at most the cap. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 90e07ddf88c..fdaa5b72d50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Removing a session from the host's map and removing its status row are ONE operation. // // The store keeps a row until told to drop it, and `structuredHostOwned` bypasses the staleness @@ -15,7 +16,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import { AgentHookServer } from '../../agent-hooks/server' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -106,16 +110,16 @@ async function workingSession(): Promise<{ journal: AgentSessionJournal records: Map }> { - const journal = await journals.open({ identity: IDENTITY, journalDir: join(root, SESSION) }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: join(root, SESSION) }) await journal.appendItem( PROMPT, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ @@ -170,7 +174,11 @@ function attachContext( } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the attach reads only the members defined here. return { - deps: { store: { getRecord: () => null }, claimKeyId: 'key-1', journalRoot: root }, + deps: { + store: { getRecord: () => null }, + claimKeyId: 'key-1', + journalDatabase: openTestJournalHostDatabase(root) + }, runtimeState: { resolveRecovery: async () => undefined, eventSinkFor: () => eventSink, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 8181110a5a6..7c37b954253 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +7,8 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -21,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -70,30 +73,34 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number if (!events) { throw new Error('seedGroupedQuestion requires an acquired session') } - events.appendItem(identity, { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) @@ -124,11 +131,11 @@ beforeEach(async () => { } })) answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index b8e198c9b6b..b282fc4df32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -6,7 +6,10 @@ import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { readAgentSessionHistory } from './agent-session-history-page' +import { + readAgentSessionHistory, + type AgentSessionHistoryScope +} from './agent-session-history-page' export function structuredAgentSessionProviderSessionMetadata( record: AgentSessionRecord | null @@ -24,8 +27,9 @@ export function readStructuredAgentSessionHistoryResult(input: { journal: AgentSessionJournal record: AgentSessionRecord | null request: AgentSessionHistoryRequest + scope?: AgentSessionHistoryScope }): AgentSessionHistoryResult { - const result = readAgentSessionHistory(input.journal, input.request) + const result = readAgentSessionHistory(input.journal, input.request, undefined, input.scope) const fence = input.record?.lease.runtimeFence const providerSession = structuredAgentSessionProviderSessionMetadata(input.record) if (fence === undefined) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index d85d1dfee0f..125cc1f0100 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -11,7 +11,9 @@ import { type OpenedStructuredAgentSessionConversation, type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostDeps, @@ -19,7 +21,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionConversationDelivery = { @@ -29,6 +30,10 @@ export type StructuredAgentSessionConversationDelivery = { sessionId: string, options?: StructuredAgentSessionConversationOpenOptions ) => Promise + /** Every commit a conversation's journal makes: one may have ended the command that held its + * queue. Enqueued through the session's serialize, never read here, so a commit that lands while + * a step is deciding to stop wakes the loop after that step rather than being lost to it. */ + afterCommit: (sessionId: string, journal: AgentSessionJournal) => void /** Stops the loop and the resettle on a proof of death; quit's first step. */ dispose: () => void /** Indexes a conversation some other open produced, as `open` would have. */ @@ -49,7 +54,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { startedFor: string ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void - publishRestored: (sessionId: string) => void + clientDelivery: Pick + flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input const loop = new StructuredAgentSessionDeliveryLoop({ @@ -61,8 +67,15 @@ export function createStructuredAgentSessionConversationDelivery(input: { conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), failureTextContext: (sessionId) => - structuredAgentSessionFailureWordsContext(deps.store.getRecord(sessionId)), - onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + structuredAgentSessionFailureWordsContext( + deps.store.getRecord(sessionId), + sessions.get(sessionId)?.journal + ), + onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + record: (sessionId) => deps.store.getRecord(sessionId), + readChildWork: input.clientDelivery.readChildWork, + flushStreamedEvents: input.flushStreamedEvents, + now: () => deps.now?.() ?? Date.now() }) const adoptOpened = async ( sessionId: string, @@ -73,12 +86,32 @@ export function createStructuredAgentSessionConversationDelivery(input: { if (reset) { input.reset(sessionId, session.journal, reset) } - input.publishRestored(sessionId) + input.clientDelivery.publishRestored(sessionId) await settleInterruptedCommands(deps, sessionId, session) if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { loop.wake(sessionId) } } + const wakesQueued = new Set() + const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + if ( + wakesQueued.has(sessionId) || + structuredAgentSessionCommandRunning(journal) || + !journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + return + } + wakesQueued.add(sessionId) + void input + .serialize(sessionId, async () => { + wakesQueued.delete(sessionId) + loop.wake(sessionId) + }) + .catch((error: unknown) => { + wakesQueued.delete(sessionId) + deps.onEventSinkError?.({ sessionId, error }) + }) + } // A chat open before its owner's death was proven revises what its open settled. Queued, never // awaited: the writer can hold this session's serialize (an attach recovering its lease). const stopResettling = deps.store.onDeathEvidence((sessionId) => { @@ -94,6 +127,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return { loop, + afterCommit, adoptOpened, dispose: () => { loop.dispose() @@ -105,10 +139,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { } /** - * A compaction or rewind found prepared when the conversation opens was started under a child - * this process no longer has — the open runs only when none is indexed — so nothing will finish - * it, and left alone it refuses every send, so no agent would ever start to. Settled here instead - * of by a start inside acceptance. A Codex rewind only its provider can prove stays for the attach. + * A rewind found prepared when the conversation opens was started under a child this process no + * longer has — the open runs only when none is indexed — so nothing will finish it, and left alone + * it refuses every send until a view attaches. Settled here instead of by a start inside + * acceptance. A Codex rewind only its provider can prove stays for the attach. */ async function settleInterruptedCommands( deps: StructuredAgentSessionHostDeps, @@ -117,7 +151,6 @@ async function settleInterruptedCommands( ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) try { - await settleInterruptedCompaction(deps.store, sessionId, session.journal, fence) await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) } catch (error) { deps.onEventSinkError?.({ sessionId, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index c9ba8714b4e..f2140c98fbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -2,8 +2,8 @@ // // Two operations, because they end two different things. Stopping the agent ends the provider // child and hands the lease back; the conversation — its open journal, its status row and its -// readers — stays, and the next send starts a new child. Closing the conversation drops the open -// journal handle, a cache the next read or write reopens. +// readers — stays, and the next send starts a new child. Closing the conversation drops its +// in-memory fold, a cache the next read or write rebuilds from the host's journal database. // // Both are written for a caller already inside the session's serialize: the queue is not // reentrant, so every public entry point takes it once and calls these. @@ -183,10 +183,10 @@ export function structuredAgentSessionConversationClosable( } /** - * Drops the conversation's open handle. The entry leaves the map BEFORE the handle closes, so a - * lock-free reader sees an open handle or none — never one that is closing — and one arriving - * after the delete waits behind this step and reopens. Answers false, closing nothing, when the - * handle is still more than a cache. + * Drops the conversation's open fold: a map delete, then its admitted writes drain. The entry + * leaves the map first, so a lock-free reader sees an open conversation or none — never one that + * is closing — and one arriving after the delete waits behind this step and reopens. Answers + * false, closing nothing, when the conversation is still more than a cache. */ export async function closeStructuredAgentSessionConversationUnderSerialize( context: Pick & { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index e5a92bf16a3..ae84046b73e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -29,6 +29,10 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { threadGoalPlan } from './structured-agent-session-thread-goal' +import { + isMainAgentWorkingOnceFlushed, + performCancel +} from './structured-agent-session-turns-cancel' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, @@ -39,6 +43,7 @@ import { openForWrite, openWithAgent, sendPreparation, + structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { @@ -48,6 +53,8 @@ import { setOptionPlan, type MutationPlan } from './structured-agent-session-mutation-plans' +import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' +import { runStopWithQueuePause } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -76,10 +83,15 @@ export type StructuredAgentSessionMutationContext = { wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ stopAgent: (sessionId: string) => Promise + /** Only for gate inputs living in the RECORD store, which can settle with no + * journal commit (a conversation command). Draft-table changes need no call: + * the draft store notifies through the journal's own commit listener. */ + wakeQueuedDrain?: (sessionId: string) => void now: () => number } -function mutate( +/** Admits the envelope and runs the plan inside the session's serialize. */ +export function mutateStructuredAgentSession( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, @@ -110,27 +122,32 @@ export function sendStructuredAgentSessionTurn( envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true + delivery?: 'queue-if-active' + /** Host-local, set only by the client-facing `agentSession.send` RPC (the + * renderer's launch prompt included): recorded as the submission's `client` + * origin, whose started turn ends a Stop's or a restart's queue pause. + * Orchestration mail, a restart continuation and `agent.launch`'s host-sent + * prompt never set it. */ + userSend?: true beforeRun?: () => void } ): Promise> { const plan = sendPlan(params) - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - run: async (ctx) => { - const blocked = structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) - if (blocked) { - return blocked - } - const accepted = await plan.run(ctx) - if (accepted.ok) { - context.wakeDelivery(ctx.sessionId) - } - return accepted - } + run: (ctx) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) }, sendPreparation(context, params.envelope) ) @@ -141,54 +158,70 @@ export function cancelStructuredAgentSessionTurn( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope - turnId: string + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } } ): Promise> { - const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand - // Interrupts must reach a provider while the command awaits its terminal frame. - const cancellationContext = - command?.command === 'compact' && command.phase === 'prepared' - ? { - ...context, - serialize: (sessionId: string, task: () => Promise) => - context.serialize(`compact-cancel:${sessionId}`, task) - } - : context - const plan = cancelPlan(params) if (params.scope || params.prompt) { - return mutate( - cancellationContext, + return mutateStructuredAgentSession( + context, caller, params.envelope, - plan, + cancelPlan(params), openForWrite(context, params.envelope) ) } - return mutate( - cancellationContext, + const plan = cancelPlan({ + ...params, + stopChild: () => context.stopAgent(params.envelope.sessionId) + }) + return mutateStructuredAgentSession( + context, caller, params.envelope, { ...plan, - run: async (ctx) => { - // Stop withdraws every queued message first, whatever the start or the child is doing. - const withdrawn = await ctx.journal.rejectQueuedSubmissions( - ctx.fence, - agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - ) - const child = context.sessions.get(ctx.sessionId)?.child - if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - await context.stopAgent(ctx.sessionId) - return { ok: true, value: { turnId: params.turnId, cancelled: true } } - } - return child - ? plan.run(ctx) - : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } - } + // Stop's queue step, the same for every client: once the Stop takes effect + // the queue is paused. The cards stay published; nothing is withdrawn and no + // text ever rides the answer. + run: (ctx) => + runStopWithQueuePause(ctx, async (tookEffect) => { + // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) + const named = params.turnId !== undefined ? { turnId: params.turnId } : {} + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. + await tookEffect() + await context.stopAgent(ctx.sessionId) + return { ok: true, value: { ...named, cancelled: true } } + } + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = params.turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) + const record = context.deps.store.getRecord(ctx.sessionId) + if (!child || !inFlight) { + if (withdrawn.length > 0) { + await tookEffect() + } + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + } + await tookEffect() + return performCancel( + { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, + { + clientOperationId: params.envelope.clientOperationId, + ...named, + stopChild: () => context.stopAgent(params.envelope.sessionId), + withdrewQueued: withdrawn.length > 0 + } + ) + }) }, openForWrite(context, params.envelope) ) @@ -199,7 +232,7 @@ export function respondToStructuredAgentSessionPrompt( caller: StructuredAgentSessionCaller, params: AgentSessionPromptRequest & { envelope: AgentSessionMutationEnvelope } ): Promise> { - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -217,7 +250,7 @@ export async function setStructuredAgentSessionOption( await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) const plan = setOptionPlan(params) const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -242,7 +275,7 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index 8d54ef52784..5f336345b5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -1,14 +1,28 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_RECORD_SCHEMA_VERSION, type AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' const NOW = 1_800_000_000_000 +// The runtime state never reads the journal database; it only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-host-runtime-state-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + function reservedRecord(): AgentSessionRecord { return { schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, @@ -48,10 +62,11 @@ function runtimeState( record: AgentSessionRecord | null, probeOwner: NonNullable ) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime state reads only store, probeOwner and optional deps; the rest of the host's deps are unused here. const deps = { store: { getRecord: () => record } as unknown as AgentSessionRecordStore, adapter: {}, - journalRoot: '/tmp', + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner } as StructuredAgentSessionHostDeps @@ -119,13 +134,14 @@ describe('host runtime-state owner probe', () => { throw new Error('lease probe unavailable') }) const record = liveRecord() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lease renewal under test reads only listRecords and getRecord from the store. const deps = { store: { listRecords: () => [record], getRecord: () => record }, adapter: {}, - journalRoot: '/tmp', + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner, onEventSinkError diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index 1baaafb6f6b..aa8692a0c73 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -48,9 +48,6 @@ describe('structured agent-session host teardown', () => { claudeWindows: CLAUDE_WINDOWS_GRACEFUL_EXIT_MS, codexWindows: CODEX_WINDOWS_GRACEFUL_EXIT_MS } - expect(CHILD_EVICTION_TIMEOUT_MS).toBe( - SNAPSHOT_DRAIN_TIMEOUT_MS + Math.max(closes.claude, closes.codex) + EVICTION_MARGIN_MS - ) for (const closeMs of Object.values(closes)) { expect(SNAPSHOT_DRAIN_TIMEOUT_MS + closeMs + EVICTION_MARGIN_MS).toBeLessThanOrEqual( CHILD_EVICTION_TIMEOUT_MS diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index bc9a48c5dac..4ce0b3885ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -1,10 +1,9 @@ // Host teardown, made failure-complete. // -// A trailing "close every journal" statement is skipped on exactly the path -// that leaks: `flushAllEventSinks` throws BY DESIGN when a sink barrier fails, -// and the attach drain can reject too. Every connection would then be left open -// with the global runtime reference already cleared — the one state from which -// nothing can ever close them. +// Every phase runs whatever an earlier one threw: `flushAllEventSinks` throws BY DESIGN when a +// sink barrier fails, and the attach drain can reject too. Each conversation is then retired — +// what it still queues settled, its admitted writes drained — before the runtime closes the one +// journal connection, last. import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' @@ -17,7 +16,6 @@ import { type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { withTimeout } from '../../../shared/promise-timeout-fallback' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export type StructuredAgentSessionTeardownPhase = { @@ -100,7 +98,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { ] } -export async function tearDownStructuredAgentSessionHost(input: { +async function tearDownStructuredAgentSessionHost(input: { phases: readonly StructuredAgentSessionTeardownPhase[] sessions: Map retainSessionIds?: ReadonlySet @@ -120,7 +118,7 @@ export async function tearDownStructuredAgentSessionHost(input: { const entries = [...input.sessions.entries()].filter( ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) - // `allSettled`, so one rejected close cannot skip the others. + // `allSettled`, so one failed settlement cannot skip the others. const closed = await Promise.allSettled( entries.map(async ([sessionId, session]) => { await input.abandonQueued?.(sessionId, session) @@ -130,8 +128,8 @@ export async function tearDownStructuredAgentSessionHost(input: { closed.forEach((result, index) => { const sessionId = entries[index]?.[0] if (result.status === 'fulfilled') { - // Only a FULFILLED close drops the entry. One that rejected stays indexed, - // which is what makes a later close a real retry rather than a no-op. + // Only a settled conversation drops out. One whose queued sends could not be settled stays + // indexed, so a later stop retries that settlement. if (sessionId !== undefined) { input.sessions.delete(sessionId) input.acknowledgeSessionRelease?.(sessionId) @@ -141,10 +139,6 @@ export async function tearDownStructuredAgentSessionHost(input: { failures.push(result.reason) }) - // Journals an earlier failure path could not close are retried HERE, which is - // the only place that owns them once their caller has unwound. - failures.push(...(await agentSessionJournalCloseRetries.retryAll())) - if (failures.length > 0) { throw new AggregateError(failures, 'agent session host teardown failed') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index b069fecd1bb..4d064cbdfaa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' @@ -18,16 +18,14 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } describe('abandoning a structured agent-session host', () => { it('waits for the restart the delivery loop woke for an accepted send', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) // Holds the restart inside its provider acquisition, so the point teardown must not run past // is exact rather than a timing window. let gate: Promise | null = null @@ -74,7 +72,7 @@ describe('abandoning a structured agent-session host', () => { outcome: 'identity-matched', matchedOn: ['process-start-time'] }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 87091073ec0..41e9ad1ab4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,8 +9,12 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -99,13 +104,17 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis if (!events) { throw new Error('seedApproval requires an acquired session') } - events.appendItem(identity, { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) @@ -140,11 +149,11 @@ beforeEach(async () => { cancelTurn = vi.fn(async () => ({ cancelled: true })) answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), recoveryCapsule: new AgentSessionRecoveryCapsule(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -153,8 +162,8 @@ beforeEach(async () => { }) afterEach(async () => { - await journals.closeAll() await host.flushAllStreamedEvents() + await journals.closeAll() await rm(root, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 44699536a6f..80ea85e8683 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -6,6 +6,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter, @@ -95,7 +96,8 @@ export type StructuredAgentSessionHostDeps = { adapter: StructuredAgentSessionAdapter /** Optional advisory recovery storage, independent of conversation backups. */ recoveryCapsule?: AgentSessionRecoveryCapsule - journalRoot: string + /** The host's one chat journal database. */ + journalDatabase: JournalHostDatabase claimKeyId: string probeOwner?: (record: AgentSessionRecord) => Promise probeOwners?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 58a84cecd5f..46bd8490592 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,10 +1,11 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { join } from 'node:path' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -26,6 +27,7 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' let root: string let store: AgentSessionRecordStore @@ -132,7 +134,7 @@ describe('attach', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), acquire }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -186,7 +188,8 @@ describe('attach', () => { }) events?.appendItem( { provider: 'orca', clientMessageId: 'old-journal-write' }, - { kind: 'status', text: 'old journal write' } + { kind: 'status', text: 'old journal write' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) const released = await store.evictProvenDeadOwner({ @@ -389,7 +392,7 @@ describe('respondToPrompt', () => { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - child + { ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) @@ -557,11 +560,11 @@ describe('restart', () => { adapterOverrides: Partial = {}, stopOwnerProcess?: StructuredAgentSessionHostDeps['stopOwnerProcess'] ) { - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), ...adapterOverrides }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-b', probeOwner, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 86529312b90..739937c0cc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -55,6 +55,7 @@ import { import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -63,16 +64,23 @@ export class StructuredAgentSessionHost { this ) private readonly sessions = new StructuredAgentSessionConversations({ - deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + deliver: (sessionId, journal) => { + this.subscribers.publish(sessionId, journal) + this.conversationDelivery.afterCommit(sessionId, journal) + }, onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() }) + private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () => + this.mutationContext() + ) // Every journal publish is activity: the one renewal the idle sweep reads. private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), () => this.deps, - (sessionId) => this.sessions.touch(sessionId), + (sessionId) => this.queued.onJournalActivity(sessionId), (sessionId) => this.restartResume.onAgentStarted(sessionId), (sessionId) => this.backgroundTasks.publish(sessionId) ) @@ -124,7 +132,8 @@ export class StructuredAgentSessionHost { reset, structuredAgentSessionConversationFence(deps.store, sessionId) ), - publishRestored: this.clientDelivery.publishRestored + clientDelivery: this.clientDelivery, + flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, @@ -171,6 +180,7 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) @@ -273,12 +283,17 @@ export class StructuredAgentSessionHost { ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: this.lifetime.stopAgent, + wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } } send = this.conversationCommands.send + queuedMessageSend = this.queued.queuedMessageSend + queuedMessageDelete = this.queued.queuedMessageDelete + queuedMessagesResume = this.queued.queuedMessagesResume + waitForSendSettlement = this.clientDelivery.waitForSendSettlement private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext()) @@ -303,8 +318,8 @@ export class StructuredAgentSessionHost { handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => structuredAgentSessionOwnerStatus(this.deps, sessionId) - history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => - this.backgroundTasks.history(request) + history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request, scope) => + this.backgroundTasks.history(request, scope) /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are * revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 798c9b14809..4140f96762b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The idle sweep on a real host: what puts an agent to rest, what keeps it running, and what a // reader and the session lists see when it does. @@ -180,7 +181,8 @@ describe('the idle sweep', () => { await foundRestTestChat(rig) providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += IDLE_MS + 1 @@ -205,7 +207,8 @@ describe('the idle sweep', () => { // Activity at 29 minutes: a provider row reaching the journal. providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 60 }, - { kind: 'status', text: 'still thinking' } + { kind: 'status', text: 'still thinking' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += 60_001 @@ -242,7 +245,8 @@ describe('the idle sweep', () => { detail: null, options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += IDLE_MS + 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 05e297a5b88..3f6732dadd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -3,7 +3,7 @@ // Nothing a viewer does keeps an agent alive or starts one: a chat on screen and a chat in a // background tab are the same to this sweep. Every few minutes it looks at each open conversation // and stops the provider child of one that has been quiet for the idle window and owes no work, -// then drops the open journal handle of one that is only a cache. The conversation itself — its +// then drops the in-memory fold of one that is only a cache. The conversation itself — its // record, tab, status row and readers — is untouched, and the next send starts a new child. // // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts new file mode 100644 index 00000000000..a173a3ad661 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -0,0 +1,134 @@ +// A chat whose per-chat file did not read back as its copy is refused as history that cannot be +// loaded: on a send and a Stop as on a read. Each retry runs the same copy, so "try again" is wrong. + +import { cp, rm } from 'node:fs/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + JournalImportMismatchError, + journalOpenRefusalError +} from '../agent-session-journal/journal-open-failure' +import { replayJournal } from '../agent-session-journal/journal-open' +import type * as PerSessionImport from '../agent-session-journal/journal-per-session-import' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) + +vi.mock('../agent-session-journal/journal-per-session-import', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // What the copy's verify throws for a file that does not read back as copied. + importPerSessionJournal: async ( + input: Parameters[0] + ) => { + if (!copy.mismatched) { + return actual.importPerSessionJournal(input) + } + throw journalOpenRefusalError( + new JournalImportMismatchError( + `per-chat journal of ${input.identity.sessionId} read back short` + ) + ) + }, + // Restore lists a chat still in its per-chat file from a fold of it, and owes the copy. + previewPerSessionJournal: async ( + input: Parameters[0] + ) => + copy.mismatched + ? replayJournal(input.database.db, input.identity.sessionId) + : actual.previewPerSessionJournal(input) + } +}) + +const UNLOADABLE = { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Unable to load this chat.', + details: { reason: 'journalCorrupt' } + } +} + +const relaunchedRoots: string[] = [] + +beforeEach(() => { + copy.mismatched = false + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A restarted process over the chat's files, holding no chat open; its copy now mismatches. */ +async function relaunchWithMismatchedCopy(): Promise { + const before = hostTestState() + await attach() + await before.host.flushStreamedEvents(SESSION) + await before.store.renewLeases([]) + const relaunched = `${before.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(before.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const store = await openTestAgentSessionRecordStore(relaunched) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + now: () => NOW + }) + replaceHostTestState({ store, host }) + copy.mismatched = true + return host +} + +function send(host: StructuredAgentSessionHost) { + const body = hostTestMessage('sent to a chat that did not copy') + return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) +} + +it('refuses a send and a Stop that open the chat as unloadable', async () => { + const host = await relaunchWithMismatchedCopy() + + await expect(send(host)).resolves.toMatchObject(UNLOADABLE) + await expect( + host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + ).resolves.toMatchObject(UNLOADABLE) + await host.flushAllStreamedEvents() +}) + +// Startup listed the chat from its per-chat file; the send's write pays the copy it owes. +it('refuses a send to a restored chat as unloadable when the copy it owes does not verify', async () => { + const host = await relaunchWithMismatchedCopy() + await host.restoreReadableSessions([SESSION]) + expect(host.hasSession(SESSION)).toBe(true) + + await expect(send(host)).resolves.toMatchObject(UNLOADABLE) + await host.flushAllStreamedEvents() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index b35690c4e7d..e7ce667ed2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -6,15 +6,16 @@ import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' -import type { StructuredAgentSessionAppendOptions } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from './structured-agent-session-event-sink' export function structuredAgentSessionJournalAppendOptions( fence: number, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): JournalItemAppendOptions { return { fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + turnScope: options.turnScope, ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts new file mode 100644 index 00000000000..3fb865f50ec --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -0,0 +1,61 @@ +// Damage SQLite reports in the middle of a session: the write that meets it fails and says the +// chat cannot be loaded, the agent can still be stopped, and nothing is renamed or rebuilt. + +import { readdir } from 'node:fs/promises' +import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' +import { JournalHostDatabase } from '../agent-session-journal/journal-host-database' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { hostTestMessage } from './structured-agent-session-host-test-data' + +let root: string +let host: StructuredAgentSessionHost +let cancelTurn: Mock + +beforeEach(() => { + ;({ root, host, cancelTurn } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +// T-corrupt-midsession. +it('refuses a send as corrupt when SQLite reports damage, and still stops the agent', async () => { + await attach() + const files = await readdir(root, { recursive: true }) + const damaged = Object.assign(new Error('database disk image is malformed'), { + code: 'ERR_SQLITE_ERROR', + errcode: 11 + }) + vi.spyOn(JournalHostDatabase.prototype, 'transaction').mockImplementation(() => { + throw damaged + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const body = hostTestMessage('after the damage') + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + expect(sent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Unable to load this chat.', + details: { reason: 'journalCorrupt' } + } + }) + // Stop reaches the agent before it writes anything; the note it then cannot record is the + // error the caller sees, after the fact. + await expect( + host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + ).rejects.toBe(damaged) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(await readdir(root, { recursive: true })).toEqual(files) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts deleted file mode 100644 index 8dabd942705..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -// Journal handle ownership across the wire layer. -// -// Every one of these sites is reached only when something has already gone -// wrong, so a happy-path assertion proves nothing about them. On POSIX a leak -// is silent; the rename/remove pair below is the half that actually fails on -// Windows. - -import { access, mkdtemp, rename, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type * as JournalLegacyImport from '../agent-session-journal/journal-legacy-import' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' -import { closeStructuredAgentSessionConversationUnderSerialize } from './structured-agent-session-host-lifetime' -import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - -const legacyImport = vi.hoisted(() => ({ throws: false })) - -vi.mock('../agent-session-journal/journal-legacy-import', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - importLegacyTranscriptIntoJournal: async ( - input: Parameters[0] - ) => { - if (legacyImport.throws) { - throw new Error('legacy import threw instead of reporting a failure') - } - return actual.importLegacyTranscriptIntoJournal(input) - } - } -}) - -const SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: SESSION } -} - -let root: string -let journalDir: string -const journals = createTrackedJournalOpener() - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -async function expectNothingHoldsTheDirectory(directory: string): Promise { - const dbPath = journalDatabaseFile(directory) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - const moved = `${directory}-moved` - await rename(directory, moved) - await rm(moved, { recursive: true }) -} - -function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSession { - return { - journal, - params: {} as StructuredAgentSessionHostSession['params'], - child: null - } -} - -beforeEach(async () => { - legacyImport.throws = false - root = await mkdtemp(join(tmpdir(), 'orca-wire-handles-')) - journalDir = join(root, 'journal') -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('site 6: recovery rehydration', () => { - it('closes the journal it opened when the legacy import throws', async () => { - const seeded = await journals.open({ identity: IDENTITY, journalDir }) - for (let ordinal = 1; ordinal <= 3; ordinal += 1) { - await seeded.appendItem( - { provider: 'codex', threadId: SESSION, turnId: 'turn-1', ordinal }, - { kind: 'status', text: `seed-${ordinal}` }, - { fence: 1 } - ) - } - await seeded.close() - // Punch a hole in the middle so recovery takes the `journal_corrupt` branch. - const { openJournalDatabase } = await import('../agent-session-journal/journal-database') - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) - opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(3) - opened.db.close() - legacyImport.throws = true - - await expect( - openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - journalDir, - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - ).rejects.toThrow('legacy import threw') - await expectNothingHoldsTheDirectory(journalDir) - }) -}) - -describe('sites 9 and 10: closing a conversation handle', () => { - it('drops the map entry before the close, and releases the handle', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) - const sessions = new Map([[SESSION, hostSession(journal)]]) - const order: string[] = [] - const close = journal.close.bind(journal) - journal.close = async () => { - // A lock-free reader arriving now must find no entry, never a closing handle. - order.push(sessions.has(SESSION) ? 'close-while-indexed' : 'close-after-delete') - await close() - order.push('closed') - } - - await expect( - closeStructuredAgentSessionConversationUnderSerialize( - { sessions, closeStatus: () => order.push('status') }, - SESSION - ) - ).resolves.toBe(true) - - expect(order).toEqual(['status', 'close-after-delete', 'closed']) - expect(sessions.size).toBe(0) - await expectNothingHoldsTheDirectory(journalDir) - }) - - it('surfaces a rejected close to its caller', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) - const sessions = new Map([[SESSION, hostSession(journal)]]) - const close = journal.close.bind(journal) - journal.close = () => Promise.reject(new Error('close rejected')) - - await expect( - closeStructuredAgentSessionConversationUnderSerialize( - { sessions, closeStatus: () => undefined }, - SESSION - ) - ).rejects.toThrow('close rejected') - journal.close = close - }) -}) - -describe('site 11: host teardown is failure-complete', () => { - async function twoSessions(): Promise> { - const first = await journals.open({ identity: IDENTITY, journalDir }) - const second = await journals.open({ - identity: { ...IDENTITY, sessionId: `${SESSION}-b` }, - journalDir: join(root, 'journal-b') - }) - return new Map([ - [SESSION, hostSession(first)], - [`${SESSION}-b`, hostSession(second)] - ]) - } - - it('closes every journal and clears the map on the happy path', async () => { - const sessions = await twoSessions() - const acknowledgeSessionRelease = vi.fn() - await tearDownStructuredAgentSessionHost({ - phases: [], - sessions, - acknowledgeSessionRelease - }) - - expect(sessions.size).toBe(0) - expect(acknowledgeSessionRelease.mock.calls).toEqual([[SESSION], [`${SESSION}-b`]]) - await expectNothingHoldsTheDirectory(journalDir) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) - - // Against a trailing-statement design this case fails: `flushAllEventSinks` - // throws by design, so the close would be skipped on exactly the leaking path. - it('still closes every journal when a teardown phase throws', async () => { - const sessions = await twoSessions() - const barrierError = new Error('sink barrier failed') - - await expect( - tearDownStructuredAgentSessionHost({ - phases: [ - { - name: 'flush-event-sinks', - run: () => { - throw barrierError - } - } - ], - sessions - }) - ).rejects.toMatchObject({ errors: [barrierError] }) - - expect(sessions.size).toBe(0) - await expectNothingHoldsTheDirectory(journalDir) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) - - it('keeps the entry whose close rejected, and surfaces the rejection', async () => { - const sessions = await twoSessions() - const acknowledgeSessionRelease = vi.fn() - const failing = sessions.get(SESSION) - const closeError = new Error('close rejected') - if (failing) { - sessions.set(SESSION, { - ...failing, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. - journal: { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal - }) - } - - await expect( - tearDownStructuredAgentSessionHost({ phases: [], sessions, acknowledgeSessionRelease }) - ).rejects.toMatchObject({ errors: [closeError] }) - - // Only the failure stays indexed — `status === 'fulfilled'`, not "settled". - expect([...sessions.keys()]).toEqual([SESSION]) - expect(acknowledgeSessionRelease).toHaveBeenCalledExactlyOnceWith(`${SESSION}-b`) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 2194b992fb0..510384f3e48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,7 +9,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionDispatchOutcome, @@ -26,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -90,7 +93,7 @@ beforeEach(async () => { resetHostTestOperationIds() dispatch = vi.fn(async () => accepted()) closeSession = vi.fn(async () => true) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -116,7 +119,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -271,7 +274,10 @@ describe('settling a send the provider proves it received after the ack window', await journal().appendItem( { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' }, params.body, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) expect(await submissions()).toMatchObject([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index 9545e42b051..9399e4d0242 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -6,7 +6,8 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' const NOW = 1_800_000_000_000 @@ -15,7 +16,7 @@ const roots: string[] = [] async function liveStore(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) roots.push(root) - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const reserved = await store.reserveOwner({ sessionId: 'session-renewal', location: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 213126fb5eb..706d0d8fc3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -10,7 +10,8 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { PersistedAgentSessionLease } from '../../../shared/agent-session-legacy-handoff-lease' import { writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -22,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -50,7 +52,7 @@ function openHost(): void { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW, @@ -71,8 +73,8 @@ async function persistFromOlderBuild(lease: OlderBuildLease): Promise { const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() // Over the attached owner: the older build's stage or terminal owner kept it from releasing. - await writeOlderBuildLease(join(root, 'store'), SESSION, { ...attached, ...lease }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await writeOlderBuildLease(root, SESSION, { ...attached, ...lease }) + store = await openTestAgentSessionRecordStore(root) acquire.mockClear() openHost() } @@ -129,7 +131,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) openHost() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts new file mode 100644 index 00000000000..aa4a9547001 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -0,0 +1,249 @@ +// The chat's own Stop and every session list read whether the main agent is working through one +// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the +// status feed. Both leave the host from one publication edge, so once each has landed they must say +// the same thing. Against the real host, store, journal, stream and status feed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../shared/structured-agent-session-reducer' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const CALLER = { callerKey: 'client-1' } +const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let events: StructuredAgentSessionEventSink | undefined +/** The session's child records, as the host's store serves them to every surface. */ +let records: AgentChildWorkView[] +let chat: StructuredAgentSessionState +let listed: AgentSessionStatusSummary | undefined + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) + resetHostTestOperationIds() + events = undefined + records = [] + chat = EMPTY_STRUCTURED_AGENT_SESSION + listed = undefined + // Written, and the provider has neither opened a turn for it nor answered it. + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async (input) => { + events = input.events + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${input.fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: input.fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted: vi.fn(async () => undefined), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) + } + }) + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + listed = structuredClone(event.session) + } else if (event.type === 'snapshot') { + listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed + } + } + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + expect( + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + ).toMatchObject({ ok: true }) + return clientOperationId +} + +async function provider(ordinal: number, body: AgentJournalItemBody): Promise { + events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + events!.publish() + await host.flushStreamedEvents(SESSION) +} + +/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ +function chatReadsWorking(): boolean { + return isStructuredAgentSessionMainAgentWorking( + activeStructuredAgentSessionTurnId(chat.items), + chat.submissions, + chat.fence + ) +} + +/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ +function listReads(): { mainAgent: string; row: string } { + const summary = listed + if (!summary?.status) { + return { mainAgent: 'none', row: 'none' } + } + const status = structuredAgentSessionAgentStatus({ + ...summary, + status: summary.status, + childWork: summary.children + }) + return { mainAgent: status.mainAgent.state, row: status.state } +} + +describe('the main agent read by the chat and by a session list', () => { + it('agrees while a rate-limited request retries with no turn open', async () => { + const id = await send('hello') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) + // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. + await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) + await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) + + expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) + expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() + expect(chatReadsWorking()).toBe(true) + expect(listReads().mainAgent).toBe('working') + }) + + it('agrees once the handed-over message is answered and only a subagent still runs', async () => { + dispatch.mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + await send('fan out') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') + ) + await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + expect(chatReadsWorking()).toBe(true) + expect(listReads().mainAgent).toBe('working') + + await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) + records = [ + { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'deep_review', + state: 'working', + membership: 'live', + firstObservedAt: NOW, + observedAt: NOW, + stoppable: false, + invocation: { invocationId: 'task-1', generation: 1 } + } + ] + host.publishChildWorkEvidence(SESSION, []) + await host.flushStreamedEvents(SESSION) + + expect(chat.backgroundTasks?.children).toHaveLength(1) + // The row reads Working for the subagent; the main agent, and so Stop, does not. + expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) + expect(chatReadsWorking()).toBe(false) + }) + + it('agrees when the child that was handed the message exits', async () => { + await send('hello') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) + expect(chatReadsWorking()).toBe(true) + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reason: 'codex app-server crashed', + cause: 'unexpected-exit' + }) + await host.flushStreamedEvents(SESSION) + + expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) + expect(chatReadsWorking()).toBe(false) + expect(listReads().mainAgent).not.toBe('working') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index fd56944f3bd..388fabd7bd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -14,7 +14,13 @@ import type { AgentSessionPromptResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, + structuredAgentSessionCompactBody +} from './structured-agent-session-command-turn' import { performCancel, performPrompt, @@ -24,6 +30,17 @@ import { type TurnOutcome } from './structured-agent-session-turns' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' + +/** The body-only hash: what the reducer recomputes to alias a provider echo + * onto its submission, so the stored value must never include control fields. */ +function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} export type MutationPlan = { method: string @@ -43,6 +60,8 @@ export function sendPlan(params: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true + delivery?: 'queue-if-active' + userSend?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -53,8 +72,9 @@ export function sendPlan(params: { operationIdScope: 'global', conversationWrite: true, markUnknownBeforeRun: true, - // A control signal is not payload; it cannot alter durable replay. - fields: { body: params.body }, + // `delivery` joins the OPERATION fingerprint only; the submission row keeps + // the body-only fingerprint the reducer's echo-aliasing recomputes. + fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. @@ -62,12 +82,19 @@ export function sendPlan(params: { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() return performSend(ctx, { + origin: params.userSend ? 'client' : 'host', clientMessageId, - payloadFingerprint: params.envelope.payloadFingerprint, + payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) }, replay: (ctx, outcome) => { + // A send this host queued answers from its draft, then its hand-off; a + // withdrawn draft replays as spent — never as missing-submission doubt. + const queued = queuedSendAnswer(ctx.journal, clientMessageId) + if (queued) { + return queued + } const submission = ctx.journal .submissions() .find((entry) => entry.clientMessageId === clientMessageId) @@ -96,19 +123,61 @@ export function sendPlan(params: { } } +export type ConversationCommandAcceptance = + | { clientMessageId: string } + /** What an older build's run of this operation recorded. */ + | { recorded: AgentSessionConversationCommandResult } + +/** `/compact` accepted like a send: one submission, keyed by the operation id, that the delivery + * loop carries out as the command's own turn. */ +export function conversationCommandPlan(params: { + envelope: AgentSessionMutationEnvelope + priorRecord: () => AgentSessionConversationCommandResult | null +}): MutationPlan { + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.conversationCommand', + conversationWrite: true, + markUnknownBeforeRun: true, + fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, + recoverUnknownFromDurableState: true, + run: async (ctx) => { + const sent = await performSend(ctx, { + clientMessageId, + // Only a client asks through the command RPC: the person's own turn. + origin: 'client', + payloadFingerprint: params.envelope.payloadFingerprint, + body: structuredAgentSessionCompactBody() + }) + return sent.ok ? { ok: true, value: { clientMessageId } } : sent + }, + replay: (ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.conversationCommand) { + return { recorded: outcome.conversationCommand } + } + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return { clientMessageId } + } + const prior = params.priorRecord() + return prior ? { recorded: prior } : null + } + } +} + export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope - turnId: string + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } + stopChild?: () => Promise }): MutationPlan { return { method: 'agentSession.cancel', // Stop is a conversation write; a prompt or background-task cancel needs the live child. ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), fields: { - turnId: params.turnId, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}) @@ -116,14 +185,18 @@ export function cancelPlan(params: { run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, - turnId: params.turnId, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), - ...(params.prompt ? { prompt: params.prompt } : {}) + ...(params.prompt ? { prompt: params.prompt } : {}), + ...(params.stopChild ? { stopChild: params.stopChild } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a - // replay reports the turn as already handled instead. - replay: () => ({ turnId: params.turnId, cancelled: false }) + // replay reports the turn as already handled. + replay: () => ({ + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), + cancelled: false + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 094ddffdcb2..71750505fef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -29,7 +29,7 @@ async function context(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: join(hostTestState().root, 'settlement') + stateDirectory: join(hostTestState().root, 'settlement') }), fence: 1, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 3eae8a7ae08..1ec8f2784a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -7,7 +7,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -19,6 +20,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -119,12 +121,12 @@ beforeEach(async () => { closeSessionExit = true dispatchedModels.length = 0 const accountHome = join(root, 'codex-home') - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ store, adapter: router, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-native', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index f8aec7d5d3b..24e9dc66f44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -19,7 +19,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -30,6 +31,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -89,11 +91,11 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 89772d45469..db9406785f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' import { AgentSessionPreSpawnError, @@ -16,6 +16,7 @@ import { } from './structured-agent-session-attach' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -65,10 +66,7 @@ function createParams(): AgentSessionAttachParams { /** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const unused = async (): Promise => { throw new Error('not reached before a spawn') } @@ -84,8 +82,8 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 348d5a3d90c..9df12ea96fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionPreSpawnError, type StructuredAgentSessionAdapter @@ -14,6 +14,7 @@ import { } from './structured-agent-session-attach' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -67,10 +68,7 @@ function attachParams( describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { @@ -86,8 +84,7 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -109,10 +106,7 @@ describe('processless structured session reservation', () => { it('refuses a replay when adapter support drifts after durable reservation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -134,8 +128,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -159,10 +153,7 @@ describe('processless structured session reservation', () => { it('releases a new reservation when support drifts before acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -174,8 +165,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-drift', claimKeyId: 'key-1', @@ -214,8 +205,7 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) - const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const adapter = { acquire: vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) @@ -227,8 +217,7 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -254,7 +243,7 @@ describe('processless structured session reservation', () => { }) expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) - const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(root) await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), now: NOW + 1 @@ -268,8 +257,7 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) - const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const adapter = { acquire: vi .fn() @@ -294,8 +282,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index a9e32b026e6..b9a7bc7c5c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -1,9 +1,10 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -35,7 +36,7 @@ afterEach(async () => { async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const item = await journal.appendItem( PROMPT_IDENTITY, { @@ -50,7 +51,7 @@ async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { journal, itemId: item.itemId } } @@ -117,7 +118,7 @@ describe('performCancel for a pending prompt', () => { resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index fb66a1e1a8f..75061435a1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The provider child is its own record on the conversation: stopping it, losing it or failing to // start it ends the child, never the conversation. Against the real host, store and journal, with a // live subscriber opened before each action. @@ -21,8 +22,8 @@ import { import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -37,6 +38,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -88,7 +90,7 @@ function startHost(): void { setOption: vi.fn(async () => undefined), ...adapterExtras }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW @@ -114,7 +116,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) await host.close(SESSION) @@ -331,15 +333,12 @@ describe('settling an earlier child before the next one takes its message', () = agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: journalDirectoryFor(root, { - workspaceId: HOST_TEST_LOCATION.workspaceId, - sessionId: SESSION - }) + database: openTestJournalHostDatabase(root) }) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence } + { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const id = await accept('for the next child') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 5da0861aa19..411e148c586 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' @@ -14,6 +15,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -56,7 +58,7 @@ function createHost( const host = new StructuredAgentSessionHost({ store, adapter: claudeAdapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner, @@ -76,8 +78,7 @@ describe('structured session provider restore', () => { it('restores a durable Claude session tab with its recorded provider', async () => { root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) resetHostTestOperationIds() - const storeDirectory = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) const attached = await host.attach( { callerKey: 'client-1' }, @@ -90,10 +91,7 @@ describe('structured session provider restore', () => { ) expect(attached).toMatchObject({ ok: true }) - const reopenedStore = await AgentSessionRecordStore.open({ - directory: storeDirectory, - hostId: 'local' - }) + const reopenedStore = await openTestAgentSessionRecordStore(root) const restarted = createHost(reopenedStore, async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index b1836983869..ff4b5e9c4ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -12,7 +12,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -21,6 +22,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -71,12 +73,12 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts new file mode 100644 index 00000000000..9cc22f9547a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -0,0 +1,161 @@ +// Queued drafts across conversation commands: a /compact is a queued message +// and then a turn, so a capable send during it becomes a card that waits for it +// like any turn, while Delete and Send-now answer at once; a /clear in flight +// admits no draft onto the source it is superseding; and a draft /clear carries +// to its replacement is fingerprinted for the replacement, so the provider's +// echo folds into its sent bubble. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_THREAD as THREAD, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +const WAIT_REFUSAL = { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCommandInFlight' }, + message: 'Wait for the conversation operation to finish.' + } +} + +function command(name: 'compact' | 'clear') { + const fields = { command: name } + return rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +async function queuedId( + result: ReturnType['result'] +): Promise { + const queued = await result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +describe('a /compact in flight', () => { + /** A /compact the provider took: a queued message, then its own turn, running until the + * provider ends it (`finishCompact`). */ + async function compactRunning(): Promise { + expect(await command('compact')).toMatchObject({ ok: true, value: { command: 'compact' } }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + } + + it('turns a capable send into a card, which waits for the compaction and then drains', async () => { + await compactRunning() + const draftId = await queuedId(rig.send('sent while compacting', 'queue-if-active').result) + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + // The compaction's turn owes work, so the drain waits behind it like any turn. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(await rig.handoff(draftId)).toBeUndefined() + rig.finishCompact() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect(await rig.drafts()).toHaveLength(0) + }) + + it('answers Delete at once, and Send-now sends its card behind the compaction like any send', async () => { + await compactRunning() + const deletedId = await queuedId(rig.send('deleted while compacting', 'queue-if-active').result) + const sentId = await queuedId(rig.send('sent now while compacting', 'queue-if-active').result) + const hung = new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)) + // Nothing holds the session's lane for the compaction's length any more. + expect(await Promise.race([rig.deleteQueued(deletedId), hung])).toMatchObject({ + ok: true, + value: { deleted: true } + }) + expect(await Promise.race([rig.sendNow(sentId), hung])).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: sentId } } + }) + // Accepted, then handed over only once the compaction ends — the order every send keeps. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(rig.dispatch).toHaveBeenCalledTimes(0) + rig.finishCompact() + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + }) +}) + +describe('/clear', () => { + it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => { + const attach = rig.host.attach.bind(rig.host) + let release: (() => void) | undefined + const released = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(rig.host, 'attach').mockImplementationOnce(async (...args) => { + await released + return attach(...args) + }) + try { + const cleared = command('clear') + // Nothing is recorded before the clear commits; wait until it is starting the replacement. + await eventually(() => expect(spy).toHaveBeenCalled()) + expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + release?.() + const done = await cleared + const replacementId = done.ok ? done.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts()).toHaveLength(0) + expect(await rig.drafts(replacementId)).toHaveLength(0) + } finally { + spy.mockRestore() + } + }) + + it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => { + const working = await rig.workingSend() + const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result) + await rig.stop() + await rig.settleAccepted(working, 'a') + const cleared = await command('clear') + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.sendNow(draftId, hostTestOperationId(), replacementId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + const journal = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal + const sent = journal?.submissions().findLast((entry) => entry.queuedMessageId === draftId) + if (!journal || !sent) { + throw new Error('expected the carried draft sent on the replacement') + } + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-echo', ordinal: 0 }, + hostTestMessage('carried text'), + { fence: sent.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const snapshot = await rig.host.journalSnapshot(replacementId) + const userBubbles = snapshot.items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'user' + ) + expect(userBubbles).toHaveLength(1) + expect(snapshot.submissions.find((entry) => entry.queuedMessageId === draftId)).toMatchObject({ + dispatchState: 'accepted' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts new file mode 100644 index 00000000000..bc4697be6df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -0,0 +1,302 @@ +// The one queue gate: admission, the drain step and Send-now consume a single +// typed hold decision, so the lists cannot drift — pinned here with a clear in +// doubt, Send-now's override set, and the replay-preference rule for a refused +// draft. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { structuredQueueHold } from './structured-agent-session-queued-messages' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +let host: QueuedMessageTestRig['host'] +let store: QueuedMessageTestRig['store'] + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() + ;({ host, store } = rig) +}) + +afterEach(() => rig.dispose()) + +const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) +const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) +const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) +const submission: QueuedMessageTestRig['submission'] = (...args) => rig.submission(...args) +const drafts: QueuedMessageTestRig['drafts'] = () => rig.drafts() +const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() +const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => + rig.settleAccepted(...args) +const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => + rig.settleRejected(...args) + +describe('the one queue gate', () => { + it('a clear an older build left prepared holds nothing: Send-now sends the draft', async () => { + await workingSend() + const queued = await send('queued behind the clear', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + // An unfinished /clear changed nothing the chat reads, so it refuses no send. + await store.setConversationCommand(SESSION, 1, { + command: 'clear', + runtimeFence: 1, + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + }) + expect(await sendNow(draftId)).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('Send-now refuses on a pending prompt and overrides a running turn', async () => { + const working = await workingSend() + const queued = await send('queued mid-turn', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(await sendNow(draftId)).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('pending request') } + }) + // Read in place: the gate runs on every admission and drain step. + const snapshot = vi.spyOn(journal, 'snapshot') + expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe( + 'prompt' + ) + expect(snapshot).not.toHaveBeenCalled() + snapshot.mockRestore() + await journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'client-1', + resolvedAt: 1 + } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The turn still runs (`working`), which Send-now alone may override. + expect(await submission(working)).toMatchObject({ dispatchState: 'pending' }) + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) +}) + +describe('replay preference', () => { + it('a replayed send whose draft was refused answers with the returned card, never the rejected submission', async () => { + const working = await workingSend() + const body = hostTestMessage('refused later') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }]) + ) + // The original reply was lost; the retry must agree with the card, or the + // same text renders twice — once on a Retry row, once on the card. + const replay = await host.send(CALLER, params) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: clientOperationId, state: 'returned' } } + }) + if (replay.ok && 'submission' in replay.value) { + throw new Error('replay answered with the rejected submission') + } + }) +}) + +describe('replay of a deleted card', () => { + it('answers withdrawn once its row is pruned, never with the rejected hand-off it came back from', async () => { + const working = await workingSend() + const body = hostTestMessage('refused, then deleted') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }]) + ) + expect(await rig.deleteQueued(clientOperationId)).toMatchObject({ + ok: true, + value: { deleted: true } + }) + // Retention later drops the tombstone; the rejected hand-off still names the draft. + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + vi.spyOn(journal.queuedMessages, 'get').mockReturnValue(null) + const replay = await host.send(CALLER, params) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: clientOperationId, state: 'withdrawn' } } + }) + if (replay.ok && 'submission' in replay.value) { + throw new Error('replay answered with the rejected hand-off') + } + }) +}) + +describe('the hand-off link on answers', () => { + it('a replayed queued send, once drained, answers with the hand-off that names its draft', async () => { + const working = await workingSend() + const body = hostTestMessage('drained later') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => + expect((await rig.handoff(clientOperationId))?.queuedMessageId).toBe(clientOperationId) + ) + const replayed = await host.send(CALLER, params) + expect(replayed).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { queuedMessageId: clientOperationId } } + }) + // Handed off under a fresh id, never the draft's (the send operation's) own. + expect( + replayed.ok && 'submission' in replayed.value && replayed.value.submission.clientMessageId + ).not.toBe(clientOperationId) + // The first send, direct, names no draft. + expect(await submission(working)).not.toHaveProperty('queuedMessageId') + }) + + it('a queued send asked again after its ledger row is gone answers with its hand-off, never sending twice', async () => { + const working = await workingSend() + const body = hostTestMessage('asked again') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const, + userSend: true as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + const handedOffAs = await rig.handoffId(clientOperationId) + // The ledger forgot the id, so the send runs again rather than replaying. + const operations = store['transactions'].state.operations + for (const [key, row] of operations) { + if (row.operationId === clientOperationId) { + operations.delete(key) + } + } + const count = (await host.journalSnapshot(SESSION)).submissions.length + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { clientMessageId: handedOffAs, queuedMessageId: clientOperationId } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count) + }) +}) + +describe('Send-now rerun', () => { + it('a Send whose answer never settled answers again with the submission it made, never re-sending it', async () => { + const working = await workingSend() + const queued = await send('refused twice', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'first refusal') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + const operationId = hostTestOperationId() + expect(await sendNow(draftId, operationId)).toMatchObject({ ok: true }) + await settleRejected(operationId, 'second refusal') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + // The host died before the Send's answer settled: its ledger row is still pending, so it reruns. + for (const row of store['transactions'].state.operations.values()) { + if (row.operationId === operationId) { + row.outcome = { status: 'pending' } + } + } + const count = (await host.journalSnapshot(SESSION)).submissions.length + expect(await sendNow(draftId, operationId)).toMatchObject({ + ok: true, + value: { clientMessageId: operationId, submission: { dispatchState: 'rejected' } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count) + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts new file mode 100644 index 00000000000..9aed0d4acb8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -0,0 +1,297 @@ +// One real-host rig for the mid-turn queue suites: store, journal, adapter +// mocks, and the send/stop/draft helpers every suite shares. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } + +export function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +export type QueuedMessageTestRig = Awaited> + +export async function createQueuedMessageTestRig() { + const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) + resetHostTestOperationIds() + // Admitted: the message is written and unanswered, so the session owes work + // until the test settles it. + const dispatch: Mock = vi.fn(async () => ({ + state: 'admitted' as const + })) + const awaitStarted: Mock> = vi.fn( + async () => undefined + ) + // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. + const compact: Mock> = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + let events: StructuredAgentSessionEventSink | undefined + const store = await openTestAgentSessionRecordStore(root) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken, events: sink }) => { + events = sink + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + + function envelope( + fields: Record, + method: string, + clientOperationId: string, + sessionId = SESSION + ) { + return { + sessionId, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId, + fields + }) + } + } + + /** A client's send, as the `agentSession.send` RPC hands it to the host; + * `internal` is a host-side sender (orchestration mail, a restart continuation). */ + function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + const result = host.send(QUEUED_RIG_CALLER, { + envelope: envelope(fields, 'agentSession.send', clientOperationId), + body, + ...(delivery ? { delivery } : {}), + ...(options?.internal ? {} : { userSend: true as const }) + }) + return { id: clientOperationId, result } + } + + function stop(clientOperationId = hostTestOperationId(), caller = QUEUED_RIG_CALLER) { + return host.cancel(caller, { + envelope: envelope({}, 'agentSession.cancel', clientOperationId) + }) + } + + function sendNow( + messageId: string, + clientOperationId = hostTestOperationId(), + sessionId = SESSION + ) { + return host.queuedMessageSend(QUEUED_RIG_CALLER, { + envelope: envelope( + { messageId }, + 'agentSession.queuedMessageSend', + clientOperationId, + sessionId + ), + messageId + }) + } + + function deleteQueued(messageId: string, clientOperationId = hostTestOperationId()) { + return host.queuedMessageDelete(QUEUED_RIG_CALLER, { + envelope: envelope({ messageId }, 'agentSession.queuedMessageDelete', clientOperationId), + messageId + }) + } + + async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + } + + /** The latest submission that hands off this draft, found by its link. */ + async function handoff(draftId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.findLast( + (entry) => entry.queuedMessageId === draftId + ) + } + + /** The submission id a draft went out under: never the draft's own id. */ + async function handoffId(draftId: string): Promise { + const sent = await handoff(draftId) + if (!sent) { + throw new Error(`draft ${draftId} has not been handed off`) + } + return sent.clientMessageId + } + + async function drafts( + sessionId = SESSION + ): Promise<{ messageId: string; state: string; paused?: true }[]> { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return (page.page.queuedMessages ?? []).map(({ messageId, state, paused }) => ({ + messageId, + state, + ...(paused ? { paused } : {}) + })) + } + + /** A first send that keeps the session working until the test settles it. */ + async function workingSend(): Promise { + const { id, result } = send('work on this') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + return id + } + + async function settleAccepted(id: string, itemId: string): Promise { + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: `turn-${itemId}`, + ordinal: 0 + } + }) + } + + /** A provider refusal, written as the host writes one: the sentence and the typed fact. */ + async function settleRejected(id: string, providerText: string): Promise { + const detail = { text: providerText, audience: 'person' as const } + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected', { detail }), { + surface: 'rejection' + }) + }) + } + + /** What the provider's translator writes when a /compact's turn ends, as a success. */ + function finishCompact(): void { + const { command } = compact.mock.calls.at(-1)![0] + events!.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) + } + + /** A host-process restart, as the queue sees it: the conversation closes, and + * opens afresh under a new instance id while its rows survive. */ + async function restartHostProcess(): Promise { + await host.close(SESSION) + rotateStructuredAgentSessionHostInstanceForTests() + } + + /** The queue's published pause: null when it sends on its own. */ + async function queuePause(sessionId = SESSION): Promise { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page.queuePause ?? null + } + + function resume(clientOperationId = hostTestOperationId()) { + return host.queuedMessagesResume(QUEUED_RIG_CALLER, { + envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId) + }) + } + + async function dispose(): Promise { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) + } + + return { + root, + store, + host, + dispatch, + awaitStarted, + compact, + finishCompact, + envelope, + send, + stop, + sendNow, + deleteQueued, + submission, + handoff, + handoffId, + drafts, + workingSend, + settleAccepted, + settleRejected, + restartHostProcess, + queuePause, + resume, + dispose + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts new file mode 100644 index 00000000000..aa9d31cbf10 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -0,0 +1,862 @@ +// Mid-turn queueing against the real host, store and journal: a capable send +// while the session owes work becomes a draft, the drain converts exactly one +// draft when the work settles, Stop holds the queue (never withdrawing text) +// until a user send starts its turn and lifts the pause, /clear carries the +// cards to its replacement session, and a refused conversion comes back as a +// returned card while a withdrawn one waits again. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +let host: QueuedMessageTestRig['host'] +let store: QueuedMessageTestRig['store'] +let dispatch: QueuedMessageTestRig['dispatch'] +let awaitStarted: QueuedMessageTestRig['awaitStarted'] + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() + ;({ host, store, dispatch, awaitStarted } = rig) +}) + +afterEach(() => rig.dispose()) + +const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) +const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) +const stop: QueuedMessageTestRig['stop'] = (...args) => rig.stop(...args) +const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) +const deleteQueued: QueuedMessageTestRig['deleteQueued'] = (...args) => rig.deleteQueued(...args) +const drafts: QueuedMessageTestRig['drafts'] = (...args) => rig.drafts(...args) +const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() +const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => + rig.settleAccepted(...args) +const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => + rig.settleRejected(...args) + +describe('accept', () => { + it('queues a capable send while the session owes work; an ordinary send still dispatches', async () => { + await workingSend() + const queued = await send('queued behind', 'queue-if-active').result + expect(queued).toMatchObject({ + ok: true, + value: { queued: { position: 1, state: 'waiting' } } + }) + // The draft is not a submission, feeds no reducer, and owes no work. + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(await drafts()).toMatchObject([{ state: 'waiting' }]) + }) + + it('replays the same queued answer for the same operation id', async () => { + await workingSend() + const body = hostTestMessage('queued behind') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { queued: { state: 'waiting' } } + }) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { state: 'waiting' } } + }) + expect(await drafts()).toHaveLength(1) + }) + + it('routes an image send to the immediate path even while working (text-only v1)', async () => { + await workingSend() + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'image-ref' as const, path: '/tmp/shot.png' }] + } + const clientOperationId = hostTestOperationId() + const result = await host.send(CALLER, { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' + }) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + + it('a send without the delivery field never queues, whatever the session is doing', async () => { + await workingSend() + const { result } = send('old client send') + expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + + it('refuses past the draft-count budget with a readable message', async () => { + await workingSend() + for (let index = 0; index < 20; index += 1) { + expect(await send(`draft ${index}`, 'queue-if-active').result).toMatchObject({ ok: true }) + } + expect(await send('one too many', 'queue-if-active').result).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('queue is full') } + }) + }) +}) + +describe('drain', () => { + it('drains a single draft when the owed work settles, and one of two drafts per settle (A1)', async () => { + const working = await workingSend() + const first = await send('first queued', 'queue-if-active').result + const second = await send('second queued', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + const firstId = first.value.queued.messageId + const secondId = second.value.queued.messageId + await settleAccepted(working, 'a') + // The drain converts the OLDEST actionable draft; the consumed submission + // owes work again, which holds the second draft (one message per turn). + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + expect(await rig.handoff(secondId)).toBeUndefined() + expect(await drafts()).toMatchObject([{ messageId: secondId, state: 'waiting' }]) + await settleAccepted(await rig.handoffId(firstId), 'b') + await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) + expect(await drafts()).toHaveLength(0) + }) + + it('takes no serialized drain step while the session is working, then drains when the work settles', async () => { + const working = await workingSend() + const flush = vi.spyOn(host, 'flushStreamedEvents') + const queued = await send('waits for the turn', 'queue-if-active').result + await send('and another', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + // Every wake during the turn is answered by the pre-check, not a step. + expect(flush).not.toHaveBeenCalled() + await settleAccepted(working, 'a') + const draftId = queued.value.queued.messageId + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect(flush).toHaveBeenCalled() + }) + + it('a refused conversion returns the card with its stored reason, and an idle send overtakes a lone returned card (N1)', async () => { + const working = await workingSend() + const queued = await send('will be refused', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + // Classified like a rejected submission: from the fact, not the sentence. + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.returnedRejection).toEqual({ + kind: 'providerRejected', + detail: { text: 'provider refused this payload', audience: 'person' } + }) + // The lone returned card traps nothing: a new capable send goes immediately. + const overtaking = await send('sent past the card', 'queue-if-active').result + expect(overtaking).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // And the card still offers Send: a fresh submission id re-delivers it. + const resent = await sendNow(draftId) + expect(resent).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + if (!resent.ok || !('submission' in resent.value)) { + throw new Error('expected the submission arm') + } + expect(resent.value.submission.clientMessageId).not.toBe(draftId) + // The answer names the card it sent; clients read that, never id equality. + expect(resent.value.submission.queuedMessageId).toBe(draftId) + expect(await drafts()).toHaveLength(0) + // Refused again: the card returns, matched through its current submission (N4). + await settleRejected(resent.value.submission.clientMessageId, 'refused again') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + }) + + it('a skipped settlement hook heals on the next drain step, not only at the next open', async () => { + const working = await workingSend() + const queued = await send('refused while the hook fails', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + } finally { + hook.mockRestore() + warn.mockRestore() + } + }) + + it('a waiting draft behind a returned card does not drain until the card is acted on (S5)', async () => { + const working = await workingSend() + const first = await send('to be refused', 'queue-if-active').result + const second = await send('waits behind the card', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + await settleAccepted(working, 'a') + const firstId = first.value.queued.messageId + const secondId = second.value.queued.messageId + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + await settleRejected(await rig.handoffId(firstId), 'refused') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: firstId, state: 'returned' }, + { messageId: secondId, state: 'waiting' } + ]) + ) + // Deleting the card unblocks the one behind it. + expect(await deleteQueued(firstId)).toMatchObject({ ok: true, value: { deleted: true } }) + await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) + }) +}) + +describe('held drafts', () => { + it('a restart pauses the queue, reason restarted, and it survives a reopen; never auto-sent', async () => { + const working = await workingSend() + const queued = await send('written before the restart', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + await settleAccepted(working, 'a') + // The queue is paused, not the card: it carries no hold of its own. + expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await host.close(SESSION) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it("a restart's pause also lifts when the user's next send starts its turn, exactly like a Stop's", async () => { + const working = await workingSend() + const queued = await send('written before the restart', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + await settleAccepted(working, 'a') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + // The user's send starting its turn lifts it, and adopts the row into this instance. + const next = send('user starts a new turn') + await next.result + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await settleAccepted(next.id, 'b') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a failed conversion leaves the draft waiting and paused with its error; Send retries', async () => { + const working = await workingSend() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const original = AgentSessionJournal.prototype.appendSubmission + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: draftId, state: 'waiting', paused: true } + ]) + ) + } finally { + append.mockRestore() + } + expect(AgentSessionJournal.prototype.appendSubmission).toBe(original) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + // A marker the client localizes, never host-authored copy. + expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + // The marker is stored on the row, so a host restart keeps "Couldn't send" + // instead of downgrading the card to a plain pause. + rotateStructuredAgentSessionHostInstanceForTests() + const restarted = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(restarted.ok && restarted.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + expect(await drafts()).toHaveLength(0) + }) +}) + +describe('Stop and Delete', () => { + it('Stop pauses the queue — from ANY client — and the cards stay published; no text rides the answer', async () => { + await workingSend() + const first = await send('first text', 'queue-if-active').result + const second = await send('second text', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + // The Stop comes from a DIFFERENT client than the one that typed the + // drafts: it must never move their text anywhere. + const operationId = hostTestOperationId() + const stopped = await stop(operationId, { callerKey: 'client-2' }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(stopped.ok && Object.keys(stopped.value).sort()).toEqual(['cancelled']) + expect(await drafts()).toEqual([ + { messageId: first.value.queued.messageId, state: 'waiting' }, + { messageId: second.value.queued.messageId, state: 'waiting' } + ]) + // One pause for the whole queue: "Queue paused because you interrupted". + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // A lost acknowledgement replays the settled Stop; still no text, no field. + const replayed = await stop(operationId, { callerKey: 'client-2' }) + expect(replayed).toMatchObject({ ok: true, replayed: true, value: { cancelled: false } }) + expect(replayed.ok && Object.keys(replayed.value).sort()).toEqual(['cancelled']) + expect(await drafts()).toHaveLength(2) + }) + + /** A draft consumed into a submission the delivery loop has not handed over: + * the loop is held at the child's start proof until the returned release. */ + async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> { + const working = await workingSend() + const queued = await send('stopped in flight', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + let release: () => void = () => undefined + awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await settleAccepted(working, 'a') + const draftId = queued.value.queued.messageId + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined() + return { draftId, release: () => release() } + } + + it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { + const { draftId, release } = await consumedButNotHandedOver() + const stopped = await stop() + release() + expect(stopped).toMatchObject({ ok: true }) + expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Nothing failed, so the card carries no refusal: it reads like any other paused card. + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const card = page.ok ? page.page.queuedMessages?.[0] : undefined + expect(card).not.toHaveProperty('returnedReason') + expect(card).not.toHaveProperty('returnedRejection') + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('the Stop pause survives eviction and reopen, and Send-now overrides it', async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(working, 'a') + // Evict the handle and reopen (the history read opens the conversation at + // rest): the pause is derived from what the journal holds, so nothing drains. + await host.close(SESSION) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + // Send-now overrides the pause — the user acting is a release. + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it("the user's next send lifts the stopped hold once its turn starts, and the held draft drains after that turn", async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + await settleAccepted(working, 'a') + // Settling the stopped turn is not the user starting one: still paused. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The host accepting the send is not yet a turn: the pause lifts when the + // provider accepts it, and the draft drains after that turn. + const next = send('user starts a new turn') + await next.result + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(next.id, 'b') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) never lifts the pause', async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + await settleAccepted(working, 'a') + // All three reach the host as a send the client send RPC did not make. + const mail = send('coordinator mail', undefined, { internal: true }) + expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // The journal records who asked, which is what the pause reads. + expect(await rig.submission(mail.id)).toMatchObject({ origin: 'host' }) + expect(await rig.submission(working)).toMatchObject({ origin: 'client' }) + await settleAccepted(mail.id, 'b') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { + const working = await workingSend() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: draftId, state: 'waiting', paused: true } + ]) + ) + } finally { + append.mockRestore() + } + const next = send('user starts a new turn') + await next.result + await settleAccepted(next.id, 'b') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + // The explicit Send is still the release. + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it('a Stop whose pause record fails still interrupts; only the pause is lost, and it is reported', async () => { + await workingSend() + const queued = await send('kept by the stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const record = vi + .spyOn(JournalQueuedMessages.prototype, 'recordPause') + .mockRejectedValueOnce(new Error('disk full')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('queue pause'), expect.anything()) + } finally { + record.mockRestore() + warned.mockRestore() + } + expect(await rig.queuePause()).toBeNull() + // The draft is intact (never withdrawn), merely unpaused. + expect(await drafts()).toEqual([{ messageId: queued.value.queued.messageId, state: 'waiting' }]) + }) + + it('Delete returns no body, replays from the receipt, and a fresh delete reports the disposition', async () => { + await workingSend() + const queued = await send('delete me', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const operationId = hostTestOperationId() + const deleted = await deleteQueued(draftId, operationId) + expect(deleted).toMatchObject({ + ok: true, + replayed: false, + value: { deleted: true, messageId: draftId } + }) + // The card leaving the published list is the whole answer. + expect(deleted.ok && Object.keys(deleted.value).sort()).toEqual(['deleted', 'messageId']) + expect(await drafts()).toHaveLength(0) + expect(await deleteQueued(draftId, operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { deleted: true, messageId: draftId } + }) + // A FRESH delete of the already-withdrawn draft reports the disposition. + expect(await deleteQueued(draftId)).toMatchObject({ + ok: true, + value: { deleted: false, disposition: 'withdrawn' } + }) + }) +}) + +describe('/clear', () => { + function clear(clientOperationId: string) { + const fields = { command: 'clear' as const } + return host.conversationCommand(CALLER, { + envelope: envelope(fields, 'agentSession.conversationCommand', clientOperationId), + ...fields + }) + } + + /** Two drafts paused by a Stop, then the work settled so command admission + * has nothing pending. */ + async function pausedDrafts(): Promise<[string, string]> { + const working = await workingSend() + const first = await send('first text', 'queue-if-active').result + const second = await send('second text', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + await stop() + await settleAccepted(working, 'a') + return [first.value.queued.messageId, second.value.queued.messageId] + } + + it('the clear schema refuses the never-shipped withdraw opt-in', () => { + const base = { envelope: envelope({}, 'agentSession.conversationCommand', 'op-schema') } + expect(ConversationCommandParams.safeParse({ ...base, command: 'clear' }).success).toBe(true) + expect( + ConversationCommandParams.safeParse({ ...base, command: 'clear', withdrawQueued: true }) + .success + ).toBe(false) + }) + + it('carries the drafts to the replacement session as visible cards on a paused queue — the same for every client', async () => { + const [firstId, secondId] = await pausedDrafts() + const operationId = hostTestOperationId() + const cleared = await clear(operationId) + expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The source's cards are spent tombstones; the replacement shows them on a + // queue paused by the clear — not "because you interrupted" — until the user + // acts: Resume, or their next send starting its turn. + expect(await drafts()).toHaveLength(0) + expect(await drafts(replacementId)).toEqual([ + { messageId: firstId, state: 'waiting' }, + { messageId: secondId, state: 'waiting' } + ]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Paused from before the first carried card lands: the idle replacement auto-sends nothing. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) + // A lost acknowledgement's replay re-runs nothing and duplicates nothing. + const replayed = await clear(operationId) + expect(replayed).toMatchObject({ ok: true, replayed: true }) + expect(await drafts(replacementId)).toHaveLength(2) + // The carried card still answers Send-now, on the replacement. + expect(await rig.sendNow(firstId, hostTestOperationId(), replacementId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + const [firstId] = await pausedDrafts() + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + const resumed = await host.queuedMessagesResume(CALLER, { + envelope: envelope( + {}, + 'agentSession.queuedMessagesResume', + hostTestOperationId(), + replacementId + ) + }) + expect(resumed).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause(replacementId)).toBeNull() + await eventually(async () => + expect( + (await host.journalSnapshot(replacementId)).submissions.some( + (entry) => entry.queuedMessageId === firstId + ) + ).toBe(true) + ) + }) + + it('a carry whose insert fails leaves no pause over the empty replacement', async () => { + await pausedDrafts() + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const insert = vi + .spyOn(JournalQueuedMessages.prototype, 'insert') + .mockRejectedValueOnce(new Error('disk full')) + let replacementId: string | undefined + try { + const cleared = await clear(hostTestOperationId()) + replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + } finally { + insert.mockRestore() + warned.mockRestore() + } + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await drafts(replacementId)).toHaveLength(0) + const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(journal?.queuedMessages.pause()).toBeNull() + }) + + it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { + const working = await workingSend() + const queued = await send('refused then cleared', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The refusal belonged to the source's submissions; on the replacement the + // text is simply a waiting draft again, behind the replacement's pause. + expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await drafts()).toHaveLength(0) + }) + + it('a clear an older build left prepared holds no draft: it drains when the turn settles', async () => { + const working = await workingSend() + const queued = await send('behind the clear', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await store.setConversationCommand(SESSION, 1, { + command: 'clear', + runtimeFence: 1, + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a clear with no drafts carries nothing and answers exactly as before', async () => { + const cleared = await clear(hostTestOperationId()) + expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await drafts(replacementId)).toHaveLength(0) + }) +}) + +describe('publication', () => { + async function subscribeEvents(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events + } + + function queuedFrames(events: AgentSessionSubscribeEvent[]): AgentSessionQueuedMessage[][] { + return events.flatMap((event) => + event.type !== 'end' && event.queuedMessages !== undefined && event.queuedMessages !== null + ? [event.queuedMessages] + : [] + ) + } + + it('hydrates the list on subscribe, publishes draft inserts at an unchanged cursor, and carries the shrunk list with the consumed submission in one frame', async () => { + const working = await workingSend() + const events = await subscribeEvents() + // Hydration: the opening snapshot carries the (empty) list. + expect(events[0]).toMatchObject({ type: 'snapshot', queuedMessages: [] }) + const queued = await send('queued behind', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + // The insert writes no journal row, yet the caught-up publish delivers it. + await eventually(() => { + const lists = queuedFrames(events) + expect(lists.at(-1)).toMatchObject([{ messageId: draftId, state: 'waiting' }]) + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + // The frame that carries the consumed submission also carries the shrunk list. + const consumeFrame = events.find( + (event) => + event.type === 'batch' && + event.batch.submissions.some((entry) => entry.queuedMessageId === draftId) + ) + expect(consumeFrame).toBeDefined() + if (consumeFrame?.type === 'batch') { + expect(consumeFrame.queuedMessages).toEqual([]) + } + }) + + it('a failed conversion reaches live subscribers as a paused card, with no further journal commit', async () => { + const working = await workingSend() + const events = await subscribeEvents() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(() => + expect(queuedFrames(events).at(-1)).toMatchObject([ + { messageId: draftId, paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } + ]) + ) + } finally { + append.mockRestore() + } + // Send releases the process-level pause, which later tests' reused ids would otherwise inherit. + expect(await sendNow(draftId)).toMatchObject({ ok: true }) + }) + + it("live frames carry the queue's pause with the list, and Resume's lift", async () => { + await workingSend() + const events = await subscribeEvents() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const pauses = () => + events.flatMap((event) => + event.type !== 'end' && event.queuePause !== undefined ? [event.queuePause] : [] + ) + await eventually(() => expect(pauses().at(-1)).toBeNull()) + await stop() + await eventually(() => expect(pauses().at(-1)).toEqual({ reason: 'stopped' })) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(pauses().at(-1)).toBeNull()) + }) + + it('an idle Stop that takes no effect pauses nothing', async () => { + const working = await workingSend() + const first = await send('to be refused', 'queue-if-active').result + const second = await send('waits behind the card', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + const firstId = first.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + await settleRejected(await rig.handoffId(firstId), 'refused') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: firstId, state: 'returned' }, {}]) + ) + // Idle, nothing in flight and nothing withdrawn: the Stop changes nothing. + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(await rig.queuePause()).toBeNull() + }) + + it('an unchanged list is not re-sent on later frames', async () => { + await workingSend() + const events = await subscribeEvents() + await send('queued behind', 'queue-if-active').result + await eventually(() => expect(queuedFrames(events).length).toBeGreaterThan(0)) + const framesAfterInsert = queuedFrames(events).length + // Another journal commit with no draft change re-sends nothing. + const { result } = send('another working send') + await result + await eventually(async () => { + const last = events.at(-1) + expect(last?.type).toBe('batch') + }) + expect(queuedFrames(events).length).toBe(framesAfterInsert) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts new file mode 100644 index 00000000000..b6d9d2a90be --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -0,0 +1,380 @@ +// Mid-turn queueing: the accept decision that turns a send into a host-held +// draft, the serialized drain that converts one draft into an ordinary +// submission when the session stops owing work, and the published draft list. +// +// Drafts are never owed work: they feed no reducer, no working status, no +// teardown and no idle sweep. The drain re-reads every gate inside its own +// serialized step, so there is no loop state to disagree with the journal. + +import { randomUUID } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionSendResult, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../shared/structured-agent-session-mutation' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' +import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + structuredAgentSessionHostInstance, + structuredQueuePause +} from './structured-agent-session-queued-pause' + +/** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the + * serialized blocks); refused readably rather than trimmed. */ +export const QUEUED_MESSAGES_MAX_COUNT = 20 +export const QUEUED_MESSAGES_MAX_TOTAL_BYTES = 1024 * 1024 + +/** Text-only v1: any image block routes to the immediate path. */ +export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean { + return body.blocks.every((block) => block.type === 'text') +} + +/** Walks the reduced items in place: the gate runs on every admission and + * drain step, so it must not render a snapshot of the whole journal. */ +export function pendingPromptExists(journal: Pick): boolean { + let pending = false + journal.visitItems((_itemId, _sequence, body) => { + if ( + !pending && + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) { + pending = true + } + }) + return pending +} + +/** Waiting, not held on its own, not positioned behind a returned card, and the + * queue not paused. The admission rule (§accept) and the drain's selection + * both read it. */ +function oldestActionableQueuedMessage( + journal: Pick +): QueuedMessageRow | null { + const rows = journal.queuedMessages.list() + // Nothing waiting costs no pause derivation: this runs on every journal publish. + if (!rows.some((row) => row.state === 'waiting') || structuredQueuePause(journal) !== null) { + return null + } + for (const row of rows) { + if (row.state === 'returned') { + // A returned card blocks everything after it until the user acts. + return null + } + if (row.state === 'waiting' && row.holdReason === null) { + return row + } + } + return null +} + +/** + * Why the queue is not sending right now — ONE decision for admission, the + * drain step and Send-now, so the lists cannot drift. Each caller's override + * policy sits next to its use: + * + * admission: `blocked` refuses (the immediate path's own refusal); any other + * hold, or an actionable backlog, queues the send as a draft. + * drain step: any hold returns early; whatever clears it publishes or + * commits, which re-derives. + * Send-now: overrides only `working` (plus FIFO order and the stored hold); + * `blocked` and `prompt` refuse readably. + * + * `blocked` is whatever refuses any send (an uncertain rewind, a cleared source); + * the rest are waits. A /compact is a queued message and then a turn, + * so it holds the queue as `working`; an older build's compaction record belongs + * to a child this host no longer runs and holds nothing. Host-local vocabulary — + * never on the wire. + */ +export type StructuredQueueHold = 'blocked' | 'working' | 'prompt' + +export function structuredQueueHold(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): StructuredQueueHold | null { + // Whatever refuses any send refuses the queue too: an uncertain rewind or a source a + // clear superseded. One rule, the immediate path's own. + if (structuredAgentSessionSendBlock(input.record)) { + return 'blocked' + } + const { journal } = input + // `prompt` outranks `working`: it is the one wait Send-now may not override, + // so a prompt raised mid-turn must not read as merely `working`. + if (pendingPromptExists(journal)) { + return 'prompt' + } + if ( + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + input.fence + ) + ) { + return 'working' + } + return null +} + +/** + * Whether a `queue-if-active` send becomes a draft: any queue hold short of + * `blocked`, or an actionable draft already exists (FIFO backlog — an + * ADMISSION rule only, never a drain gate). A lone returned card, or a paused + * queue, does not trap a new send: the user acting now wins, and that send's + * turn starting is what lifts the pause — Orca's own queue policy, a stated + * deviation from held-head backlog counting. + */ +export function shouldQueueStructuredAgentSessionSend(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): boolean { + const hold = structuredQueueHold(input) + if (hold === 'blocked') { + // The immediate path's own refusal (`structuredAgentSessionSendBlock`) + // answers; queueing behind a fence would strand the draft. + return false + } + if (hold !== null) { + return true + } + return oldestActionableQueuedMessage(input.journal) !== null +} + +/** A draft's payload fingerprint in the session that will send it: the reducer + * aliases the provider's echo to the submission by recomputing exactly this. */ +export function queuedMessageFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + +/** The accept-side budget refusal, or null when the draft fits. */ +export function queuedMessageBudgetRefusal( + journal: AgentSessionJournal, + body: AgentJournalMessageItem +): AgentSessionWireRefusal | null { + const unsettled = journal.queuedMessages.list().filter(isUnsettledQueuedMessage) + const bytes = unsettled.reduce( + (sum, row) => sum + Buffer.byteLength(JSON.stringify(row.body.blocks), 'utf8'), + Buffer.byteLength(JSON.stringify(body.blocks), 'utf8') + ) + if (unsettled.length >= QUEUED_MESSAGES_MAX_COUNT || bytes > QUEUED_MESSAGES_MAX_TOTAL_BYTES) { + return { + code: 'agent_session_operation_invalid', + message: 'The message queue is full. Send again after the current turn ends.' + } + } + return null +} + +/** + * The accept branch: a capable send while the session is working (or behind an + * actionable backlog) becomes a draft instead of a submission. Returns null for + * the immediate path — an incapable client, an image body (text-only v1), a + * replayed id the journal already answers, or an idle session. + */ +export async function maybeQueueStructuredAgentSessionSend( + context: { + deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } + }, + ctx: { + sessionId: string + journal: AgentSessionJournal + fence: number + }, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + } +): Promise< + | { ok: true; value: AgentSessionSendResult } + | { ok: false; refusal: AgentSessionWireRefusal } + | null +> { + const clientMessageId = params.envelope.clientOperationId + if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + return null + } + // Asked again with no ledger answer: a send this host queued answers as its replay would — + // its hand-off goes out under a fresh id, so no submission under this id guards it. + const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId) + if (queuedBefore) { + return { ok: true, value: queuedBefore } + } + // A recorded direct submission under this id replays through today's path. + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return null + } + // A newer Orca's journal takes no new draft: the immediate path refuses the send. + if ( + ctx.journal.isReadOnly || + !shouldQueueStructuredAgentSessionSend({ + journal: ctx.journal, + record: context.deps.store.getRecord(ctx.sessionId), + fence: ctx.fence + }) + ) { + return null + } + const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body) + if (refusal) { + return { ok: false, refusal } + } + // The insert notifies through the journal's commit listener: publication and + // the drain re-derive with no call here to forget. + const row = await ctx.journal.queuedMessages.insert({ + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }) + return { + ok: true, + value: { + clientMessageId, + queued: { messageId: row.messageId, position: row.position, state: row.state } + } + } +} + +export type QueuedMessageDrainDeps = { + sessions: ReadonlyMap + getRecord: (sessionId: string) => AgentSessionRecord | null + serialize: (sessionId: string, task: () => Promise) => Promise + /** The streamed-event barrier: a turn-open already accepted by the host is + * committed before the gates are read, so no stored busy flag is needed. */ + flushStreamedEvents: (sessionId: string) => Promise + conversationFence: (sessionId: string) => number + /** The consumed submission is ordinary #22821 work from here on. */ + wakeDelivery: (sessionId: string) => void + onError: (sessionId: string, error: unknown) => void +} + +/** + * The serialized drain. Woken by every journal commit (turn, submission, prompt, + * command and Stop settlements are all commits), by draft mutations, and by the + * conversation opening; each step re-derives everything and consumes at most one + * draft — the consumed submission then owes work, which gates the next. + */ +export class StructuredAgentSessionQueuedMessageDrain { + private readonly scheduled = new Set() + + constructor(private readonly deps: QueuedMessageDrainDeps) {} + + schedule(sessionId: string): void { + const journal = this.deps.sessions.get(sessionId)?.journal + if (!journal || journal.isReadOnly) { + return + } + // Cheap pre-check so token streams do not pay a serialized step per delta. + // Skipping while working is safe: whatever ends the work is itself a commit + // that schedules again, and the step re-reads every gate after its flush. + try { + if ( + !journal.queuedMessages.settlementOwed() && + (oldestActionableQueuedMessage(journal) === null || + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + this.deps.conversationFence(sessionId) + )) + ) { + return + } + } catch { + // The handle is opening or closing; the next commit re-schedules. + return + } + if (this.scheduled.has(sessionId)) { + return + } + this.scheduled.add(sessionId) + void this.deps + .serialize(sessionId, () => { + this.scheduled.delete(sessionId) + return this.step(sessionId) + }) + .catch((error: unknown) => { + this.scheduled.delete(sessionId) + this.deps.onError(sessionId, error) + }) + } + + private async step(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || session.journal.isReadOnly) { + return + } + await this.deps.flushStreamedEvents(sessionId) + const journal = session.journal + if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { + // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. + await journal.queuedMessages.settleOwed().catch((error: unknown) => { + this.deps.onError(sessionId, error) + }) + } + const next = oldestActionableQueuedMessage(journal) + if (!next) { + return + } + const record = this.deps.getRecord(sessionId) + const fence = this.deps.conversationFence(sessionId) + // Live facts only, through the one gate; the backlog is never a gate, so a + // lone draft drains. Whatever clears a hold publishes or commits, which + // re-derives this step. + if (structuredQueueHold({ journal, record, fence }) !== null) { + return + } + // Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality. + const submissionId = createStructuredAgentSessionOperationId(randomUUID) + try { + await journal.appendSubmission( + { + clientMessageId: submissionId, + // The queue's own automatic send: it never ends a pause. + origin: 'host', + payloadFingerprint: next.fingerprint, + body: next.body, + fence, + handoverRecorded: true + }, + { + messageId: next.messageId, + expect: 'waiting', + settledByOp: null, + hostInstance: structuredAgentSessionHostInstance() + } + ) + } catch (error) { + if (error instanceof QueuedMessageNotConsumableError) { + // Lost a race with a Send-now or Delete; their transition stands. + return + } + // Pre-consume failure: the draft stays waiting, held with the marker on + // the card (a stored fact, so it survives eviction and restart). The + // hold's own commit notification publishes it. An explicit Send retries; + // no automatic retry loop. + await journal.queuedMessages + .hold({ messageIds: [next.messageId], reason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }) + .catch(() => {}) + throw error + } + this.deps.wakeDelivery(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts new file mode 100644 index 00000000000..9da974a44a4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -0,0 +1,334 @@ +// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and +// /clear's carry of the source's drafts to its replacement session. Settling +// operations stamp op-scoped tombstone receipts, so a lost acknowledgement +// replays from the rows themselves — never from the operation ledger, which +// records only that an operation happened. No mutation returns draft text: +// the published list is the one authority a client renders. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import { + isJournalWrittenByNewerOrca, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' +import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { + queuedMessageFingerprint, + structuredQueueHold +} from './structured-agent-session-queued-messages' +import { + resumeStructuredQueue, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' +import { unsettledQueuedMessages } from './structured-agent-session-queued-stop' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + openForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +function invalid(message: string): { + ok: false + refusal: { code: 'agent_session_operation_invalid'; message: string } +} { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +function submissionFor( + ctx: AgentSessionTurnContext, + clientMessageId: string +): AgentJournalSubmission | undefined { + return ctx.journal.submissions().find((entry) => entry.clientMessageId === clientMessageId) +} + +/** One transaction, stamped with the operation's caller-scoped key so a replay + * answers "spent" from the receipts. Withdrawal retires any hold in the same + * UPDATE, and the store's commit notification publishes the change. */ +export async function withdrawQueuedMessagesForOperation( + journal: AgentSessionJournal, + input: { + sessionId: string + messageIds: readonly string[] + callerKey: string + operationId: string + } +): Promise { + return journal.queuedMessages.withdraw({ + messageIds: input.messageIds, + settledByOp: agentSessionOperationKey(input.callerKey, input.operationId) + }) +} + +/** + * /clear's carry: the source's unsettled drafts become rows on the replacement + * session — the SAME for every client version, with no text on the wire — so the + * cards stay visible where the user now is. The replacement's queue starts + * paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just + * discarded, so they wait for the user's next turn there, or Resume, rather than + * sending into the fresh context unasked. Each card lands with the pause in one + * transaction, so the drain never sees a carried card unpaused and no pause is + * left over an empty queue if an insert fails. Runs after the + * replacement's attach succeeded and before the clear commits. Each insert is + * idempotent on (session, message), so a retried clear replays it safely; the source rows are then tombstoned. Bookkeeping around the clear: + * a failure leaves the cards on the superseded source — whose supersession + * fence already blocks the drain — reported, never gating the clear. A crash + * between the copy and the tombstone leaves both, which the fence also makes + * harmless: nothing is lost and nothing runs. + */ +export async function carryQueuedMessagesToClearReplacement( + ctx: AgentSessionTurnContext, + input: { + replacementSessionId: string + replacementJournal: AgentSessionJournal | undefined + callerKey: string + operationId: string + } +): Promise { + try { + const rows = unsettledQueuedMessages(ctx.journal) + if (rows.length === 0) { + return + } + const replacement = input.replacementJournal + if (!replacement) { + throw new Error('the replacement journal is not open') + } + for (const row of rows) { + // A returned card carries over as a plain waiting draft — its refusal + // belonged to the source's submissions. The fingerprint is re-scoped to the + // replacement, or its echo could never alias the sent bubble. + await replacement.queuedMessages.insert({ + messageId: row.messageId, + body: row.body, + fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), + hostInstance: structuredAgentSessionHostInstance(), + pausedBy: 'cleared' + }) + } + await withdrawQueuedMessagesForOperation(ctx.journal, { + sessionId: ctx.sessionId, + messageIds: rows.map((row) => row.messageId), + callerKey: input.callerKey, + operationId: input.operationId + }) + } catch (error) { + console.warn("[agent-session] /clear's queued-draft carry skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** Draft actions run like any mutation: admitted on the session's lane, the + * conversation opened for the write. */ +function mutateQueued( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): Promise> { + return mutateStructuredAgentSession( + context, + caller, + envelope, + { ...plan, run: (ctx) => refusingNewerOrcaJournal(plan.run(ctx)) }, + openForWrite(context, envelope) + ) +} + +/** A newer Orca's journal refuses a draft write with the words a send gets there. */ +async function refusingNewerOrcaJournal( + run: Promise> +): Promise> { + try { + return await run + } catch (error) { + if (isJournalWrittenByNewerOrca(error)) { + return { ok: false, refusal: journalOpenRefusal(error) } + } + throw error + } +} + +/** + * Send-now. It overrides ONLY queue policy — FIFO order, pause, the busy-turn + * wait — through the same send block and pending-prompt gates as any send; + * supersession, Stop and prepared commands are never overridden. The card goes + * out under this operation's id, never its own, and the submission names it by + * `queuedMessageId`; one id still means one delivery. + */ +export function sendQueuedStructuredAgentMessage( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; messageId: string } +): Promise> { + const { messageId } = params + const operationId = params.envelope.clientOperationId + const plan: MutationPlan = { + method: 'agentSession.queuedMessageSend', + fields: { messageId }, + conversationWrite: true, + run: async (ctx): Promise> => { + // A rerun of this operation after it consumed the card (its answer never + // settled): answer with the submission it made, never append it again. + const consumedHere = submissionFor(ctx, operationId) + if (consumedHere?.queuedMessageId === messageId) { + return { ok: true, value: { clientMessageId: operationId, submission: consumedHere } } + } + // The one queue gate; Send-now's override set is exactly `working` (plus + // FIFO order and the stored hold, which the consume below clears). + const record = context.deps.store.getRecord(ctx.sessionId) + const hold = structuredQueueHold({ journal: ctx.journal, record, fence: ctx.fence }) + if (hold === 'blocked') { + return structuredAgentSessionSendBlock(record) ?? invalid('This conversation cannot send.') + } + if (hold === 'prompt') { + return invalid('Answer the pending request before sending this message.') + } + const row = ctx.journal.queuedMessages.get(messageId) + if (!row) { + return invalid('No queued message by that id.') + } + if (row.state === 'withdrawn') { + return invalid('This queued message was withdrawn.') + } + if (row.state === 'dispatched') { + // Already a submission — answer with it rather than sending twice. + const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) + return submission + ? { ok: true, value: { clientMessageId: submission.clientMessageId, submission } } + : invalid('This queued message was already sent.') + } + const submissionId = operationId + try { + await ctx.journal.appendSubmission( + { + clientMessageId: submissionId, + // The person asked for this turn, so it ends a Stop's pause once it starts. + origin: 'client', + payloadFingerprint: row.fingerprint, + body: row.body, + fence: ctx.fence, + handoverRecorded: true + }, + { + messageId, + expect: row.state, + settledByOp: agentSessionOperationKey(ctx.resolvedBy, operationId), + hostInstance: structuredAgentSessionHostInstance() + } + ) + } catch (error) { + if (error instanceof QueuedMessageNotConsumableError) { + return invalid('The queued message changed underneath this Send; try again.') + } + throw error + } + const submission = submissionFor(ctx, submissionId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + context.wakeDelivery(ctx.sessionId) + return { ok: true, value: { clientMessageId: submissionId, submission } } + }, + replay: (ctx) => { + const opKey = agentSessionOperationKey(ctx.resolvedBy, operationId) + const row = ctx.journal.queuedMessages + .receipts(opKey) + .find((receipt) => receipt.messageId === messageId) + if (!row || row.state !== 'dispatched') { + return null + } + const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) + return submission ? { clientMessageId: submission.clientMessageId, submission } : null + } + } + return mutateQueued(context, caller, params.envelope, plan) +} + +/** Delete = discard, with no body in the answer: the card leaving the published + * list IS the outcome, so a lost answer needs no re-ask. An Edit is the client + * copying the text it already renders, then this Delete. */ +export function deleteQueuedStructuredAgentMessage( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; messageId: string } +): Promise> { + const { messageId } = params + const operationId = params.envelope.clientOperationId + const plan: MutationPlan = { + method: 'agentSession.queuedMessageDelete', + fields: { messageId }, + conversationWrite: true, + run: async (ctx): Promise> => { + const row = ctx.journal.queuedMessages.get(messageId) + if (!row) { + return { ok: true, value: { deleted: false, messageId, disposition: 'missing' } } + } + if (row.state === 'dispatched') { + return { ok: true, value: { deleted: false, messageId, disposition: 'dispatched' } } + } + if (row.state === 'withdrawn') { + return { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } + } + // The withdrawal notifies through the journal's commit listener, which + // also re-derives the drain — deleting a returned card can unblock the + // drafts behind it. + const withdrawn = await withdrawQueuedMessagesForOperation(ctx.journal, { + sessionId: ctx.sessionId, + messageIds: [messageId], + callerKey: ctx.resolvedBy, + operationId + }) + return withdrawn.length > 0 + ? { ok: true, value: { deleted: true, messageId } } + : { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } + }, + replay: (ctx) => { + const replayed = ctx.journal.queuedMessages + .receipts(agentSessionOperationKey(ctx.resolvedBy, operationId)) + .some((row) => row.messageId === messageId && row.state === 'withdrawn') + return replayed ? { deleted: true, messageId } : null + } + } + return mutateQueued(context, caller, params.envelope, plan) +} + +/** Resume: ends the queue's pause — a Stop's, or a restart's — so the cards send + * again, oldest first, as the session goes idle. A no-op when nothing is paused, + * and a per-card `send_failed` hold stays for its own Send. */ +export function resumeStructuredAgentQueue( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope } +): Promise> { + const plan: MutationPlan = { + method: 'agentSession.queuedMessagesResume', + fields: {}, + conversationWrite: true, + // The lift notifies through the journal's commit listener, which publishes the + // cleared pause and wakes the drain. + run: async (ctx) => ({ + ok: true, + value: { resumed: await resumeStructuredQueue(ctx.journal) } + }), + // Like Stop's replay: the Resume already ran, so this one lifts nothing. + replay: () => ({ resumed: false }) + } + return mutateQueued(context, caller, params.envelope, plan) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts new file mode 100644 index 00000000000..2c5332db1bb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -0,0 +1,93 @@ +// A chat journal a newer Orca wrote opens read-only here: its cards still show, and every queue +// write (a queued send, Send-now, Delete, Resume) is refused with the words a send gets there. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +const REFUSED_BY_NEWER_ORCA = { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } +} + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** The chat reopens on a database a newer Orca stamped. The host's database object is the one + * the rig built, so its open-time verdict is set in place; every store write checks that. */ +async function reopenOnNewerOrcaDatabase(close: () => Promise): Promise { + await close() + Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true }) +} + +/** A read-only journal's history answers as a `schema_unreadable` reset carrying its page. */ +async function readOnlyQueue() { + const answer = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(answer).toMatchObject({ ok: false, reset: 'schema_unreadable' }) + const page = 'page' in answer ? answer.page : undefined + return { + cards: (page?.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })), + pause: page?.queuePause ?? null + } +} + +async function twoCardsBehindWork() { + await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + return { + first, + second, + cards: [ + { messageId: first, state: 'waiting' }, + { messageId: second, state: 'waiting' } + ] + } +} + +describe("a newer Orca's journal", () => { + it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { + const { first, second, cards } = await twoCardsBehindWork() + await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION)) + expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + + // The waiting cards would queue this send behind them; the journal takes no new draft. + expect(await rig.send('third', 'queue-if-active').result).toMatchObject(REFUSED_BY_NEWER_ORCA) + expect(await rig.sendNow(first)).toMatchObject(REFUSED_BY_NEWER_ORCA) + expect(await rig.deleteQueued(second)).toMatchObject(REFUSED_BY_NEWER_ORCA) + + expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + }) + + it('refuses Resume of the queue a restart paused with the update words', async () => { + const { cards } = await twoCardsBehindWork() + await reopenOnNewerOrcaDatabase(() => rig.restartHostProcess()) + expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) + + expect(await rig.resume()).toMatchObject(REFUSED_BY_NEWER_ORCA) + + expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts new file mode 100644 index 00000000000..1349a6a178e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -0,0 +1,476 @@ +// A Stop pauses the whole queue, derived from the journal: it lasts until a turn +// a person asked for (a send over the client RPC, or a card they sent now) +// starts — the provider accepts it, never merely the host — or they Resume. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HOST_TEST_SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + QUEUED_RIG_CALLER, + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { sameQueuePause } from './structured-agent-session-queued-publication' +import { + structuredAgentSessionHostInstance, + structuredQueuePause +} from './structured-agent-session-queued-pause' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +/** No drain step may convert the drafts, and the queue reads paused. */ +async function expectPaused(...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** A draft behind a Stop, with the stopped turn settled so the session is idle. */ +async function stoppedDraft(): Promise { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + return draftId +} + +/** A queued draft handed off and handed over, found by its hand-off link. */ +async function handedOver(draftId: string): Promise { + await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) +} + +/** A user send the host accepted and handed over, still unanswered by the provider. */ +async function handedOverUserSend(text: string): Promise { + const { id, result } = rig.send(text) + expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) + return id +} + +describe("a Stop's queue pause", () => { + it('outlives a user send the provider accepts and then refuses; a later send that starts lifts it', async () => { + const draftId = await stoppedDraft() + const refused = await handedOverUserSend('the start fails') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleRejected(refused, 'turn/start refused') + await expectPaused(draftId) + const started = await handedOverUserSend('this one starts') + await rig.settleAccepted(started, 'started') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a Stop after the user send supersedes it: that send starting its turn lifts nothing', async () => { + const draftId = await stoppedDraft() + const earlier = await handedOverUserSend('sent before the second stop') + await rig.stop() + await rig.settleAccepted(earlier, 'late') + await expectPaused(draftId) + }) + + it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { + const draftId = await stoppedDraft() + const inFlight = await handedOverUserSend('sent before the restart') + // Derived from the journal, not remembered: a restart forgets nothing it needs. + await rig.restartHostProcess() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleAccepted(inFlight, 'after-restart') + // The Stop's pause is over; the restart's own lasts until a turn asked for since it. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) + const next = rig.send('sent after the restart') + await next.result + await rig.settleAccepted(next.id, 'next') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { + const working = await rig.workingSend() + const olderId = await queuedDraft('paused by the stop') + await rig.stop() + const typedId = await queuedDraft('typed while stopping') + await rig.settleAccepted(working, 'stopped') + await expectPaused(olderId, typedId) + expect(await rig.drafts()).toEqual([ + { messageId: olderId, state: 'waiting' }, + { messageId: typedId, state: 'waiting' } + ]) + }) + + it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now') + const heldId = await queuedDraft('held until that turn starts') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect(await rig.sendNow(sentId)).toMatchObject({ + ok: true, + value: { submission: { origin: 'client', queuedMessageId: sentId } } + }) + await handedOver(sentId) + // Only the card the user asked for went: the queue is still paused. + await expectPaused(heldId) + expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }]) + // A turn the user asked for has now started, which ends the pause. + await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') + await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) + }) + + it('a card sent now that the provider refuses lifts nothing', async () => { + const working = await rig.workingSend() + // Ahead of the refused card, so its return blocks nothing Resume would send. + const heldId = await queuedDraft('held by the stop') + const sentId = await queuedDraft('sent now, refused') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await rig.sendNow(sentId) + await handedOver(sentId) + await rig.settleRejected(await rig.handoffId(sentId), 'turn/start refused') + await expectPaused(heldId) + }) + + it('an old send answered again after its ledger row is gone lifts nothing from a later Stop', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + // The ledger forgot the id, so the send runs again and answers with its accepted submission. + const operations = rig.store['transactions'].state.operations + for (const [key, row] of operations) { + if (row.operationId === working) { + operations.delete(key) + } + } + const body = hostTestMessage('work on this') + const replayed = await rig.host.send(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ body }, 'agentSession.send', working), + body, + userSend: true + }) + expect(replayed).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + // A later journal commit re-derives the pause; the old send was accepted before the Stop. + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') + await expectPaused(draftId) + }) +}) + +describe('the pause read', () => { + it("costs no scan of the submissions: the reducer keeps the latest person's accepted turn", async () => { + const draftId = await stoppedDraft() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + const scan = vi.spyOn(journal, 'submissions') + // Read on every publish, per subscriber: it must not walk the submissions. + expect(structuredQueuePause(journal)).toEqual({ reason: 'stopped' }) + expect(scan).not.toHaveBeenCalled() + scan.mockRestore() + const before = journal.queuedMessages.latestPersonTurnSequence() + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') + // Orca's own turn moves nothing; a person's does, and lifts the pause. + expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(before) + const next = rig.send('user starts a new turn') + await next.result + await rig.settleAccepted(next.id, 'next') + expect(journal.queuedMessages.latestPersonTurnSequence()).toBe( + journal.submission(next.id)?.acceptedSequence + ) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) +}) + +describe('a pause is over the cards it paused', () => { + it('a Stop over an empty queue pauses nothing: a card typed during a later mail turn drains', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const followUp = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(followUp)).toBeDefined()) + expect(await rig.queuePause()).toBeNull() + }) + + it('a Stop over an empty queue pauses nothing: a correction typed before the turn ends drains', async () => { + const working = await rig.workingSend() + await rig.stop() + const correction = await queuedDraft('typed right after the stop') + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + }) + + it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { + const working = await rig.workingSend() + const only = await queuedDraft('paused, then deleted') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) +}) + +describe('a pause only over cards Resume could send', () => { + it('a Stop that leaves only a returned card publishes no pause and keeps no fact', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('refused before the stop') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) + ) + // A lone returned card traps nothing: this send goes now, and the Stop interrupts it. + const next = await handedOverUserSend('sent past the card') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(next, 'stopped') + expect(await rig.queuePause()).toBeNull() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + expect(journal?.queuedMessages.pause()).toBeNull() + }) + + it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { + const working = await rig.workingSend() + const refusedId = await queuedDraft('refused after the stop') + const behindId = await queuedDraft('waits behind the card') + await rig.settleAccepted(working, 'a') + await handedOver(refusedId) + // The Stop interrupts the refused card's turn and pauses the card behind it. + await rig.stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleRejected(await rig.handoffId(refusedId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: refusedId, state: 'returned' }, + { messageId: behindId, state: 'waiting' } + ]) + ) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + // Resume would send nothing past the returned card, so no header offers it; the pause stays. + expect(await rig.queuePause()).toBeNull() + expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // Deleting the blocking card shows the pause again: the card behind it does not send unasked. + expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) + await expectPaused(behindId) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(behindId)).toBeDefined()) + }) + + it('a restart over only a card held by its own failed send publishes no pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('conversion fails once') + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await rig.settleAccepted(working, 'a') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting', paused: true }]) + ) + } finally { + append.mockRestore() + } + await rig.restartHostProcess() + // Only its own Send releases that card: a queue-level Resume would send nothing. + expect(await rig.queuePause()).toBeNull() + }) + + it('compares a pause by presence before reason, so appearing or clearing is always a change', () => { + expect(sameQueuePause(null, {})).toBe(false) + expect(sameQueuePause({}, null)).toBe(false) + expect(sameQueuePause(null, null)).toBe(true) + expect(sameQueuePause({ reason: 'stopped' }, { reason: 'stopped' })).toBe(true) + expect(sameQueuePause({ reason: 'stopped' }, { reason: 'cleared' })).toBe(false) + }) +}) + +describe("a restart's pause", () => { + it("once a person's turn ends it, stays ended when the conversation reopens", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + const next = rig.send('user starts a new turn') + await next.result + await rig.settleAccepted(next.id, 'b') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + // Reopened, that turn is "before this open", yet the pause it ended stays ended: + // the lift adopted the rows into this process. + await rig.host.close(HOST_TEST_SESSION) + expect(await rig.queuePause()).toBeNull() + expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + }) +}) + +describe('a card handed off after a restart', () => { + it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('refused, then re-sent after a restart') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) + ) + await rig.restartHostProcess() + // Sent again in this process, then withdrawn by a Stop before the agent had it. + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + await rig.stop() + release() + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + ) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( + structuredAgentSessionHostInstance() + ) + // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: null + }) + expect(structuredQueuePause(journal)).toBeNull() + }) +}) + +describe('Resume', () => { + it('lifts the pause and the queue drains, oldest first; a second Resume is a no-op', async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await expectPaused(first, second) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + // Nothing is paused now: another Resume changes nothing. + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) + + it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused twice') + await rig.stop() + const operationId = hostTestOperationId() + expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) + await rig.stop() + expect(await rig.resume(operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + await rig.settleAccepted(working, 'stopped') + await expectPaused(draftId) + }) + + it("lifts a restart's pause too", async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('is a no-op on a queue that is not paused', async () => { + await rig.workingSend() + await queuedDraft('waiting behind the turn') + expect(await rig.queuePause()).toBeNull() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) +}) + +describe('a failed Stop', () => { + it('records nothing when it fails before taking effect, so the queue sends as if no Stop was pressed', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('queued before the stop') + const reject = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockRejectedValueOnce(new Error('disk full')) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + reject.mockRestore() + } + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('keeps its pause when it fails after the interrupt reached the agent', async () => { + await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + const append = AgentSessionJournal.prototype.appendItem + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'appendItem') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + // The status note written after the provider was asked to stop. + if (args[1].kind === 'status') { + throw new Error('disk full') + } + return append.apply(this, args) + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + } + await expectPaused(draftId) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts new file mode 100644 index 00000000000..1428de75fe6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -0,0 +1,108 @@ +// Whether the queue is paused, and why — DERIVED, never stored as a flag. The +// queue is paused when: +// - 'stopped': the user's last Stop took effect (its recorded journal +// position) and no turn a person asked for has started since — or +// 'cleared', the same for a /clear's replacement, whose carried cards +// start paused; or +// - 'restarted': a waiting draft was written by another host process and no +// turn a person asked for has started since this conversation opened. +// A person's turn is a submission whose recorded origin is `client` (a send over +// the client send RPC, or a card they sent now) that the provider accepted. +// Orchestration mail, a restart continuation, a host-sent launch prompt and the +// queue's own drain are `host` and never lift it. An explicit Resume lifts any. + +import { randomUUID } from 'node:crypto' +import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' + +/** A per-process id, minted once per host process like the runtime's own + * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another + * instance pauses the queue rather than auto-sending after a restart. */ +let hostInstance = randomUUID() + +export function structuredAgentSessionHostInstance(): string { + return hostInstance +} + +/** Simulates a host-process restart. Tests only. */ +export function rotateStructuredAgentSessionHostInstanceForTests(): string { + hostInstance = randomUUID() + return hostInstance +} + +type PauseJournal = Pick + +// Both read the reducer's latest accepted person turn (its submission row), so a +// derivation on every publish costs no scan of the submissions. + +function stopEnded(journal: PauseJournal, stop: QueuePauseFact): boolean { + const latest = journal.queuedMessages.latestPersonTurnSequence() + // Sent after the Stop: a send made before it no longer lifts it, even if its turn starts later. + return stop.epoch !== journal.cursor().epoch ? latest > 0 : latest > stop.sequence +} + +function restartPending(journal: PauseJournal): boolean { + return journal.queuedMessages + .list() + .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) +} + +function restartEnded(journal: PauseJournal): boolean { + const latest = journal.queuedMessages.latestPersonTurnSequence() + return latest > 0 && !journal.wroteBeforeOpen(latest) +} + +/** The queue's pause, derived; null when the queue sends on its own. */ +export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePause | null { + const stop = journal.queuedMessages.pause() + if (stop && !stopEnded(journal, stop)) { + return { reason: stop.reason } + } + if (restartPending(journal) && !restartEnded(journal)) { + return { reason: 'restarted' } + } + return null +} + +/** + * Every journal publish: retire what a person's started turn already ended — the + * Stop fact it superseded, and a restart's rows, adopted into this instance. The + * derivation already reads them as lifted; the write keeps that answer when the + * handle reopens (the restart's "since this conversation opened" moves) and spares + * later derivations the submission scan. Bookkeeping: a failure is reported. + */ +export async function retireEndedQueuePause( + sessionId: string, + journal: PauseJournal +): Promise { + try { + const stop = journal.queuedMessages.pause() + const retireStop = stop !== null && stopEnded(journal, stop) ? stop : null + const adopt = restartPending(journal) && restartEnded(journal) + if (retireStop === null && !adopt) { + return + } + await journal.queuedMessages.liftPause({ + stop: retireStop, + adoptInto: adopt ? hostInstance : null + }) + } catch (error) { + console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** Resume: ends whichever pause holds the queue. Returns whether it was paused. */ +export async function resumeStructuredQueue(journal: PauseJournal): Promise { + if (structuredQueuePause(journal) === null) { + return false + } + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: hostInstance + }) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts new file mode 100644 index 00000000000..10b1f75fc3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -0,0 +1,112 @@ +// The published view of a conversation's queue: its whole draft list and the +// queue's pause, on the `commands` precedent — read per emit, reference-stable +// while unchanged, so the subscribers' identity dedup keeps token streams from +// re-sending it. The two ride together: a client never sees one without the other. + +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { hasResumableQueuedMessage } from '../agent-session-journal/queued-message-pause-table' +import { structuredQueuePause } from './structured-agent-session-queued-pause' + +export type QueuePublication = { + queuedMessages: AgentSessionQueuedMessage[] + queuePause: AgentSessionQueuePause | null +} + +/** Waiting and returned rows only. `paused` is a per-card hold (a failed + * conversion); a Stop or a restart pauses the queue, published once beside it. */ +function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const published: AgentSessionQueuedMessage[] = [] + for (const row of journal.queuedMessages.list()) { + if (row.state !== 'waiting' && row.state !== 'returned') { + continue + } + const held = row.state === 'waiting' && row.holdReason !== null + published.push({ + messageId: row.messageId, + position: row.position, + body: row.body, + state: row.state, + ...(held ? { paused: true as const } : {}), + // The stored reason is a typed marker; an unknown one reads as a plain hold. + ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED + ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } + : {}), + ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), + ...(row.state === 'returned' && row.returnedRejection + ? { returnedRejection: row.returnedRejection } + : {}) + }) + } + return published +} + +type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } + +/** Reference-stable per journal handle: an unchanged list is never + * re-serialized onto token-stream frames, and any draft-table write changes + * the reference by construction. */ +const listMemos = new WeakMap() +const publications = new WeakMap() + +function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const key = String(journal.queuedMessages.revision()) + const memo = listMemos.get(journal) + if (memo && memo.key === key) { + return memo.list + } + const list = computePublishedQueuedMessages(journal) + // Belt for the identity dedup: equal recomputed content keeps the previous reference. + const serialized = JSON.stringify(list) + if (memo && memo.serialized === serialized) { + listMemos.set(journal, { key, serialized, list: memo.list }) + return memo.list + } + listMemos.set(journal, { key, serialized, list }) + return list +} + +/** Presence first: a pause appearing or clearing is a change even when neither side + * names a reason this build can read. */ +export function sameQueuePause( + previous: { reason?: string } | null, + next: { reason?: string } | null +): boolean { + return (previous === null) === (next === null) && previous?.reason === next?.reason +} + +export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { + const queuedMessages = readPublishedQueuedMessages(journal) + // Read per emit: the pause also turns on submissions (a person's turn starting). + // Kept over any card it holds back, but shown only over one Resume would send, so its + // header never offers to send nothing; deleting a blocking returned card shows it again. + const pausable = hasResumableQueuedMessage(journal.queuedMessages.list()) + const queuePause = pausable ? structuredQueuePause(journal) : null + const previous = publications.get(journal) + if ( + previous && + previous.queuedMessages === queuedMessages && + sameQueuePause(previous.queuePause, queuePause) + ) { + return previous + } + const publication = { queuedMessages, queuePause } + publications.set(journal, publication) + return publication +} + +/** For readers that must never fail on drafts — a subscriber stream, a history + * page: a closing handle answers "no claim" (absent) instead of throwing. */ +export function tryReadQueuePublication( + journal: AgentSessionJournal | undefined +): QueuePublication | undefined { + try { + return journal ? readQueuePublication(journal) : undefined + } catch { + return undefined + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts new file mode 100644 index 00000000000..e0eb83e0a0b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -0,0 +1,42 @@ +// What a send this host queued answers with when it is asked again — a lost +// acknowledgement's replay, or a rerun the operation ledger no longer covers: +// from its draft first, then from the hand-off that names it. + +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** Null for a send this host never queued. A refused conversion answers with + * its returned card, never the rejected submission, or the same text would + * render twice — on a Retry row AND the card. */ +export function queuedSendAnswer( + journal: Pick, + clientMessageId: string +): AgentSessionSendResult | null { + const draft = journal.queuedMessages.get(clientMessageId) + if (draft) { + const consumed = + draft.state === 'dispatched' && draft.consumedAs !== null + ? journal.submission(draft.consumedAs) + : undefined + return consumed + ? { clientMessageId, submission: consumed } + : { + clientMessageId, + queued: { messageId: draft.messageId, position: draft.position, state: draft.state } + } + } + // The draft row was pruned; its last hand-off still names it. Only a withdrawn row is pruned + // while its last hand-off stands rejected — a card the user deleted — so it answers withdrawn, + // never as a refused send. + const handoff = journal + .submissions() + .findLast((entry) => entry.queuedMessageId === clientMessageId) + if (handoff?.dispatchState === 'rejected') { + // The pruned row's position went with it; a withdrawn receipt names no place in the queue. + return { + clientMessageId, + queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } + } + } + return handoff ? { clientMessageId, submission: handoff } : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts new file mode 100644 index 00000000000..d411efc4239 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -0,0 +1,34 @@ +// A send's queue step around its immediate path: the queue decision before it. A +// person's send lifts a paused queue through its recorded origin once its turn +// starts (`structured-agent-session-queued-pause.ts`), not through anything here. + +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runQueueableStructuredAgentSessionSend( + context: StructuredAgentSessionMutationContext, + ctx: AgentSessionTurnContext, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + userSend?: true + }, + immediate: () => Promise> +): Promise> { + // The queue decision runs first: a capable send while the session owes work (a + // /compact included — it is a queued message like any other) becomes a draft; + // only a `blocked` hold, which never queues, falls through to the refusal. + const queued = await maybeQueueStructuredAgentSessionSend(context, ctx, params) + if (queued) { + return queued + } + const accepted = await immediate() + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts new file mode 100644 index 00000000000..16a9b4aeaf7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -0,0 +1,66 @@ +// Stop's pause on the queue. A Stop never withdraws a draft and no text ever +// travels back over the wire: it records WHERE in the journal it took effect, +// and the queue is paused from there (`structured-agent-session-queued-pause.ts` +// derives it) until a turn a person asked for starts, or they Resume. The cards +// stay published, and Send-now sends one card without lifting the pause for the +// rest until that card's turn starts. The record is bookkeeping: a failure is +// reported and never gates the interrupt. + +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + isUnsettledQueuedMessage, + type QueuedMessageRow +} from '../agent-session-journal/queued-message-table' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +/** The one unsettled-card predicate /clear's carry and the budget share: + * waiting or returned. Pending/unknown/accepted deliveries stay outside it. */ +export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMessageRow[] { + return journal.queuedMessages.list().filter(isUnsettledQueuedMessage) +} + +/** + * Runs a Stop and records its queue pause at the point it takes effect — after + * it withdrew the queued sends, as it reaches the agent, or, reaching no agent, + * once it withdrew something — and only over cards it then holds back. The Stop + * calls `tookEffect` there. A Stop that throws before then changed nothing and + * recorded nothing, so there is nothing to undo; one that fails after it keeps + * the pause, since the interrupt may have landed. A draft whose hand-off the + * Stop withdrew is back to waiting in its own place, under this same pause. The + * drain cannot slip a draft in between: it runs on the same serialized lane as + * the Stop. + */ +export async function runStopWithQueuePause( + ctx: AgentSessionTurnContext, + stop: (tookEffect: () => Promise) => Promise> +): Promise> { + let attempted = false + return stop(async () => { + if (attempted) { + return + } + attempted = true + const { queuedMessages } = ctx.journal + // A hand-off this Stop's withdrawal sent back may not have caught up yet (its hook + // was skipped): heal it first, as the drain would, so the pause sees it waiting. + try { + if (queuedMessages.settlementOwed()) { + await queuedMessages.settleOwed() + } + } catch (error) { + report(ctx, 'owed settlement', error) + } + // Recorded only over a card it holds back — judged in its own transaction, which + // still counts an owed return to waiting if that heal failed. + await queuedMessages + .recordPause('stopped') + .catch((error: unknown) => report(ctx, 'queue pause', error)) + }) +} + +function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { + console.warn(`[agent-session] Stop's ${step} skipped:`, { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts new file mode 100644 index 00000000000..2b193d5cb82 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -0,0 +1,64 @@ +// Host wiring for mid-turn queueing: builds the serialized drain from the +// host's mutation context and exposes the draft actions (Send, Delete, Resume), so the host +// class stays a description of its surface. + +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' +import { retireEndedQueuePause } from './structured-agent-session-queued-pause' +import { + deleteQueuedStructuredAgentMessage, + resumeStructuredAgentQueue, + sendQueuedStructuredAgentMessage +} from './structured-agent-session-queued-mutations' + +/** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal, + * which the drain's schedule rides); everything else comes from the host's mutation context, + * read lazily because the host's fields are still initializing when this is built. */ +export function wireStructuredAgentSessionQueuedMessages( + sessions: ReadonlyMap & { + touch: (sessionId: string) => void + }, + context: () => StructuredAgentSessionMutationContext +) { + const drain = new StructuredAgentSessionQueuedMessageDrain({ + sessions, + getRecord: (sessionId) => context().deps.store.getRecord(sessionId), + serialize: (sessionId, task) => context().serialize(sessionId, task), + flushStreamedEvents: (sessionId) => context().flushStreamedEvents(sessionId), + conversationFence: (sessionId) => + structuredAgentSessionConversationFence(context().deps.store, sessionId), + wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), + onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error }) + }) + return { + drain, + /** Every journal publish: turn, submission, prompt, command and Stop + * settlements are all commits, and each re-derives the drain's gates — + * and retires a queue pause a person's started turn already ended. */ + onJournalActivity: (sessionId: string) => { + sessions.touch(sessionId) + const journal = sessions.get(sessionId)?.journal + if (journal && !journal.isReadOnly) { + void retireEndedQueuePause(sessionId, journal) + } + drain.schedule(sessionId) + }, + queuedMessageSend: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => sendQueuedStructuredAgentMessage(context(), caller, params), + queuedMessageDelete: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => deleteQueuedStructuredAgentMessage(context(), caller, params), + queuedMessagesResume: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => resumeStructuredAgentQueue(context(), caller, params) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts new file mode 100644 index 00000000000..403cb08a405 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -0,0 +1,209 @@ +// A consumed draft whose submission a Stop withdrew before the agent had it is +// not a failure: it waits again at its own position under the Stop's hold, so +// it never blocks the paused cards behind it. After the user's next turn the +// whole queue drains one per turn in queue order, the withdrawn draft first, +// under a fresh submission id. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +async function submissionIds(): Promise { + return (await rig.host.journalSnapshot(SESSION)).submissions.map((entry) => entry.clientMessageId) +} + +async function handedOver(id: string): Promise { + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) +} + +it('Stop, then a user send: the withdrawn draft and the paused cards behind it drain one per turn, in queue order', async () => { + const working = await rig.workingSend() + const a = await queuedDraft('A') + const b = await queuedDraft('B') + const c = await queuedDraft('C') + // The turn ends and the drain consumes A; the agent's start is held, so A is not handed over. + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + // Never under the draft's own id: the submission names A by its link. + const firstA = await rig.handoffId(a) + expect(firstA).not.toBe(a) + expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined() + + expect(await rig.stop()).toMatchObject({ ok: true }) + release() + // A is back in its place, behind the same queue pause as B and C: no returned card blocks them. + const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' })) + expect(await rig.drafts()).toEqual(paused) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + const d = rig.send('D') + await d.result + await handedOver(d.id) + expect(await rig.drafts()).toEqual(paused) + await rig.settleAccepted(d.id, 'd') + + // After D's turn, A drains first, under another fresh id: the first names the withdrawn submission. + const before = new Set([working, firstA, d.id]) + let resentA = '' + await eventually(async () => { + const fresh = (await submissionIds()).filter((id) => !before.has(id)) + expect(fresh).toHaveLength(1) + resentA = fresh[0] ?? '' + }) + expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') + // Both hand-offs of A name it; D, a direct send, names no draft. + expect((await rig.submission(resentA))?.queuedMessageId).toBe(a) + expect((await rig.submission(firstA))?.queuedMessageId).toBe(a) + expect(await rig.submission(d.id)).not.toHaveProperty('queuedMessageId') + expect((await rig.submission(resentA))?.payloadFingerprint).toBe( + (await rig.submission(firstA))?.payloadFingerprint + ) + expect(await rig.drafts()).toEqual([ + { messageId: b, state: 'waiting' }, + { messageId: c, state: 'waiting' } + ]) + + await handedOver(resentA) + await rig.settleAccepted(resentA, 'a') + await eventually(async () => expect((await rig.handoff(b))?.queuedMessageId).toBe(b)) + expect(await rig.handoff(c)).toBeUndefined() + await handedOver(await rig.handoffId(b)) + await rig.settleAccepted(await rig.handoffId(b), 'b') + await eventually(async () => expect(await rig.handoff(c)).toBeDefined()) + expect(await rig.drafts()).toEqual([]) +}) + +it('a Stop that fails after withdrawing a consumed draft releases it, and it sends again under a fresh id', async () => { + const working = await rig.workingSend() + const a = await queuedDraft('A') + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + const firstA = await rig.handoffId(a) + const withdraw = AgentSessionJournal.prototype.rejectQueuedSubmissions + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + const withdrawn = await withdraw.apply(this, args) + // Only the Stop's own withdrawal fails, after it landed; the delivery loop's pass through. + if (args[1].rejection.kind === 'cancelled') { + throw new Error('disk full') + } + return withdrawn + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + release() + } + expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') + const before = new Set([working, firstA]) + await eventually(async () => { + expect((await submissionIds()).filter((id) => !before.has(id))).toHaveLength(1) + expect(await rig.drafts()).toEqual([]) + }) +}) + +/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it; + * the settlement hook throws on that withdrawal's row, leaving the card owed a return. */ +async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { + const working = await rig.workingSend() + const a = await queuedDraft('A') + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + const settle = JournalQueuedMessages.prototype.onRowInTransaction + let skipped = false + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementation(function (this: JournalQueuedMessages, db, row) { + if (!skipped && row.kind === 'dispatch' && row.state === 'rejected') { + skipped = true + throw new Error('bookkeeping failed') + } + return settle.call(this, db, row) + }) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await rig.stop()).toMatchObject({ ok: true }) + } finally { + hook.mockRestore() + warned.mockRestore() + release() + } + expect(skipped).toBe(true) + return { a, working } +} + +it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => { + const { a } = await stopWithSkippedSettlement() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + // Healed back to waiting, but the Stop's pause holds it: it does not send. + expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === a + ) + ).toHaveLength(2) + ) +}) + +it('the pause is recorded even when healing the skipped settlement fails, since the card is still owed', async () => { + const heal = vi + .spyOn(JournalQueuedMessages.prototype, 'settleOwed') + .mockRejectedValueOnce(new Error('disk full')) + try { + const { a } = await stopWithSkippedSettlement() + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // The drain heals it later; the pause recorded over the owed card holds it then. + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) + ) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === a + ) + ).toHaveLength(1) + } finally { + heal.mockRestore() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 514008e49f9..83238e95d74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -13,6 +13,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' const SESSION_ID = 'codex_read_restore_fixture' @@ -50,7 +54,7 @@ const store = { let journalRoot: string const openDeps = () => ({ store, - journalRoot, + journalDatabase: openTestJournalHostDatabase(journalRoot), adapter: {} }) const opened: AgentSessionJournal[] = [] @@ -71,6 +75,7 @@ beforeEach(async () => { afterEach(async () => { await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() await rm(journalRoot, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 0e1441b729c..cf80396d820 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,6 +1,7 @@ -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' import { existsSync } from 'node:fs' -import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' +import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' +import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { openStructuredAgentSessionConversationJournal, type OpenedStructuredAgentSessionConversation, @@ -20,13 +21,22 @@ export async function restoreStructuredAgentSessionRead( if (!record) { return null } - const journalDir = journalDirectoryFor(deps.journalRoot, { - workspaceId: record.location.workspaceId, - sessionId - }) - // A session still in the pre-SQLite format has no `journal.db`; the open imports it. - if (!existsSync(journalDatabaseFile(journalDir)) && !findJournalFileFormatRemnant(journalDir)) { - return null + const database = deps.journalDatabase + if (readJournalSessionEpoch(database.db, sessionId) === null) { + // Not in the host's database yet: its history may still sit in a per-chat file the open + // imports, or in the pre-SQLite format the open explains. + const legacyDirectory = database.legacyDirectoryFor({ + workspaceId: record.location.workspaceId, + sessionId + }) + if ( + !existsSync(legacyJournalDatabaseFile(legacyDirectory)) && + !findJournalFileFormatRemnant(legacyDirectory) + ) { + return null + } } - return openStructuredAgentSessionConversationJournal(deps, record) + return openStructuredAgentSessionConversationJournal(deps, record, { + deferPerSessionImport: true + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index d1cc9408a62..5eaedfffb38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -1,5 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) @@ -9,6 +16,13 @@ vi.mock('./structured-agent-session-restart-restore', () => ({ import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +// The restore pool is mocked; the host database only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + describe('StructuredAgentSessionReadableRestorer', () => { beforeEach(() => { restoreOnRestart.mockReset().mockResolvedValue(undefined) @@ -21,7 +35,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: { store: { getRecord: () => null, listRecords: () => records }, - journalRoot: '/tmp/journals', + journalDatabase: openTestJournalHostDatabase(stateDirectory), adapter: {} }, supportsRecord: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index a2be1610327..f7d26c154f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, // and nothing along the way may call it a success. Every hop is the real one: durable journal, @@ -12,7 +13,7 @@ import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' import { AgentHookServer, _internals } from '../../agent-hooks/server' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { settleStaleStructuredAgentSessionState, @@ -54,17 +55,17 @@ async function sessionWithRunningTurn() { providerHandle: { kind: 'codex', threadId: THREAD } }, now: () => clock, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: TURN_STARTED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const server = new AgentHookServer() const sessions = new Map([[SESSION, indexedStatusFeedSession({ journal })]]) @@ -196,7 +197,7 @@ describe('a turn recovery settled after its host went away', () => { startedAt: TURN_STARTED, completedAt: EXIT_OBSERVED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) session.publish() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 2da22f95873..f69fbad1031 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -6,7 +6,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { spawnProcess } from '../../../shared/child-process/run-process' import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-owner-probe' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -20,6 +21,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -81,7 +83,7 @@ function openHost(overrides: Partial = {}): void host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW, @@ -91,7 +93,7 @@ function openHost(overrides: Partial = {}): void async function reopenStore(): Promise { await abandonStructuredAgentSessionHost(host) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) } beforeEach(async () => { @@ -112,7 +114,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) openHost() }) @@ -295,7 +297,7 @@ describe('recovery exits', () => { const outgoingHost = host const outgoingStore = store supersededHosts.add(outgoingHost) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) const realProbe = createStructuredAgentSessionOwnerProbe('local') let overlapDriven = false openHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 063e8c64af7..8e3be9d1f3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -8,7 +8,8 @@ import { readPersistedLease, writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { supervisedPosixLaunch } from '../../codex/codex-app-server-posix-supervisor' import { resolveStructuredSessionRecovery, @@ -32,10 +33,7 @@ async function newStoreDirectory(): Promise { } async function openStore(directory?: string): Promise { - return AgentSessionRecordStore.open({ - directory: directory ?? (await newStoreDirectory()), - hostId: 'local' - }) + return openTestAgentSessionRecordStore(directory ?? (await newStoreDirectory())) } async function reserve(store: AgentSessionRecordStore) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 4043e27b30c..09ba97ee8e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -16,7 +16,8 @@ import { agentSessionRefusalOperationState, type AgentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' @@ -27,6 +28,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -57,10 +59,7 @@ function operationId(timestamp = NOW): string { async function createHarness(options: { attached?: boolean } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const setOption = vi.fn(async () => undefined) const adapter: StructuredAgentSessionAdapter = { acquire: async ({ fence }) => ({ @@ -91,7 +90,7 @@ async function createHarness(options: { attached?: boolean } = {}) { const host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index 005986adb44..d3c15de933c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -63,7 +64,11 @@ describe('resolved revisions', () => { const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { - expect(deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix))).toEqual({ + expect( + deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) } @@ -77,7 +82,9 @@ describe('resolved revisions', () => { it('skips a revision that resolves to nothing', async () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.tryReviseResolvedItem?.(1_000, () => null) + deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) deferred.bind(target) await expect(deferred.drained()).resolves.toEqual({ ok: true }) @@ -88,8 +95,12 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) const published: string[] = [] vi.mocked(target.publish).mockImplementation(() => @@ -105,10 +116,14 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItem?.(bytes, () => ({ - identity: ROW, - body: text('a'.repeat(64)) - })) + deferred.sink.tryReviseResolvedItem?.( + bytes, + () => ({ + identity: ROW, + body: text('a'.repeat(64)) + }), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) deferred.bind(journalTarget(rows)) await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) expect(rows.size).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts index de1190aefc1..eb2fbea97fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -4,7 +4,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal } from './structured-agent-session-event-sink' @@ -30,7 +30,7 @@ export function createStructuredAgentSessionResolvedAppend( const submit = ( reservedBytes: number, resolve: (journal: StructuredAgentSessionRevisionJournal) => ResolvedItem | null, - options: StructuredAgentSessionAppendOptions, + options: Omit, publish: boolean ) => queue.submit( @@ -58,7 +58,7 @@ export function createStructuredAgentSessionResolvedAppend( options ) const identityOnly = (publish: boolean) => - ((identitySizeBound, body, resolveIdentity, options = {}) => + ((identitySizeBound, body, resolveIdentity, options) => submit( estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + (publish ? 1 : 0), (journal) => { @@ -71,11 +71,11 @@ export function createStructuredAgentSessionResolvedAppend( return { tryAppendResolvedItem: identityOnly(false), tryAppendResolvedItemAndPublish: identityOnly(true), - tryReviseResolvedItem: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItem: (reservedBytes, resolve, options) => submit(reservedBytes, resolve, options, false), - tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options) => submit(reservedBytes + 1, resolve, options, true), - tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options = {}) => { + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options) => { const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) return queue.submit( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 3113323b740..a5b6ba3e4be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -14,7 +14,8 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -31,6 +32,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -134,10 +136,7 @@ export async function createRestTestRig( forget: vi.fn(), readChildWork: vi.fn((): AgentChildWorkView[] => []) } - let store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { acquire: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, @@ -167,7 +166,7 @@ export async function createRestTestRig( answerPrompt: async ({ commit }) => commit(), setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -187,10 +186,7 @@ export async function createRestTestRig( sink, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(root) rig.store = store rig.host = hostFor(overrides) rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 50008ce79c4..b16f76c1633 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -6,7 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts index cfbdd9ce35e..4dff44ff2ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -13,7 +13,8 @@ const MESSAGE = 'continuation-1' let submission: AgentJournalSubmission const journal = { submissions: (): AgentJournalSubmission[] => [submission], - cursor: () => ({ epoch: 'epoch-1', sequence: 1 }) + cursor: () => ({ epoch: 'epoch-1', sequence: 1 }), + activeTurnId: () => null } beforeEach(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 9dd04929385..8038e90e1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -6,16 +6,20 @@ // message asks the agent to verify its last action before repeating it, and the launch toast // reports what happened. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { readAgentSessionFailureFact, type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionSendResult +import { + agentSessionSendSubmission, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -92,9 +96,13 @@ export function restartContinuationDeps( } }), awaitSettlement: async (sessionId, clientMessageId) => - (await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, + agentSessionSendSubmission( + (await host.awaitSendSettlement(sessionId, clientMessageId))?.value + ), awaitHandedOver: async (sessionId, clientMessageId) => - (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value.submission, + agentSessionSendSubmission( + (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value + ), onNoteFailed: host.onNoteFailed, note: restartNoteWriter(host) } @@ -113,7 +121,8 @@ function restartNoteWriter( await session.journal.appendItem( { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, { kind: 'status', text, ...(tone ? { tone } : {}) }, - { fence } + // About the conversation, not any turn in it. + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } } @@ -151,8 +160,10 @@ export type StructuredAgentSessionContinuationDeps = { }) => Promise<{ ok: boolean refusal?: { code: string } - /** The submission is where the provider's answer lives; the envelope only says Orca took it. */ - value?: { submission?: { dispatchState?: string; reason?: string | null } } + /** The submission is where the provider's answer lives; the envelope only says Orca took it. + * A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so + * the host's union return stays assignable. */ + value?: { submission?: { dispatchState?: string; reason?: string | null }; queued?: unknown } }> /** * Waits for that send's dispatch to stop being `pending`, through the host's existing settlement diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 2e785fc34a0..9bb2173f42f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -8,7 +9,8 @@ import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE } from '../../runtime/agent-session-recovery-capsule' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -31,6 +33,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -67,7 +70,8 @@ export async function interruptedRestart( // An earlier exchange had finished; the user's next send had not opened a turn yet. events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'completed' } + { kind: 'turn', turnId: 'earlier-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) } @@ -80,7 +84,8 @@ export async function interruptedRestart( } else if (work === 'children') { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'settled-turn', state: 'completed' } + { kind: 'turn', turnId: 'settled-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const group = { provider: 'codex', @@ -99,25 +104,23 @@ export async function interruptedRestart( } ] }) - events.appendItem(group, roster('working')) + events.appendItem(group, roster('working'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) children = [childRecord({ id: 'child-1', kind: 'agent', description: 'Review loop 4' })] // As the real adapters do: the child's own close settles the children it can no longer hear. previous.host.deps.adapter.closeSession = async () => { - events.appendItem(group, roster('unverifiable')) + events.appendItem(group, roster('unverifiable'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return true } } else { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'interrupted-turn', state: 'running' } + { kind: 'turn', turnId: 'interrupted-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await previous.host.flushStreamedEvents(SESSION) await previous.host.flushAllStreamedEvents() - const store = await AgentSessionRecordStore.open({ - directory: join(previous.root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(previous.root) const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. const clock = { now: NOW + 1 } @@ -136,7 +139,7 @@ export async function interruptedRestart( } : {}) }, - journalRoot: previous.root, + journalDatabase: openTestJournalHostDatabase(previous.root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', probeOwner, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index aefa34f4b67..a9aeb826a7f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -3,9 +3,8 @@ import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { rename, writeFile, rm } from 'node:fs/promises' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -36,24 +35,16 @@ it('deletes the offer once the user sends their own message in that chat', async // A journal this host cannot read decides nothing, so it must not end the offer. it('keeps the offer when the chat cannot be read on this host', async () => { - const { host, root, store } = await interruptedRestart('submission') + const { host, root } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) - const location = store.getRecord(SESSION)?.location - if (!location) { - throw new Error('missing session record') - } - // A file where the journal directory belongs: this host cannot open the conversation at all. - const journalDir = journalDirectoryFor(root, { - workspaceId: location.workspaceId, - sessionId: SESSION - }) - await rename(journalDir, `${journalDir}.aside`) - await writeFile(journalDir, 'not a journal') + // This host cannot open the conversation at all. + const open = vi + .spyOn(AgentSessionJournal.prototype, 'open') + .mockRejectedValue(Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR' })) try { expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) } finally { - await rm(journalDir) - await rename(`${journalDir}.aside`, journalDir) + open.mockRestore() } expect(await capsule.list(NOW)).toHaveLength(1) expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index b45e08314a0..59ff6958d53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdir, rm, writeFile } from 'node:fs/promises' import { AgentSessionRecoveryCapsule, @@ -279,7 +280,8 @@ it('serializes teardown publication behind an explicit dismissal', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.restartResume.beginTeardown('quit') @@ -437,7 +439,8 @@ it('logs teardown capsule publication failure and still releases the provider', } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) const capsulePath = join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 90875832c8f..c7d04ea64cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -1,6 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' const { restoreRead } = vi.hoisted(() => ({ restoreRead: vi.fn() @@ -12,9 +19,16 @@ vi.mock('./structured-agent-session-read-restore', () => ({ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +// The restore under test is mocked; the host database only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, - journalRoot: '/tmp/journals', + journalDatabase: openTestJournalHostDatabase(stateDirectory), adapter: {} } @@ -64,6 +78,44 @@ describe('restart journal restoration', () => { expect(peak).toBe(4) }) + it('lets the event loop run between chats', async () => { + // Counts turns of the event loop while the restore runs; each open below is synchronous. + let turns = 0 + let ticking = true + const tick = (): void => { + turns += 1 + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + const turnsSeen = new Set() + restoreRead.mockImplementation(async () => { + turnsSeen.add(turns) + return null + }) + const records = Array.from( + { length: 12 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + + await restoreStructuredAgentSessionsOnRestart({ + openDeps: NO_OPEN_DEPS, + records, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined + }) + ticking = false + + expect(restoreRead).toHaveBeenCalledTimes(records.length) + // At most one chat per worker between two turns: never the whole restore in one task. + expect(turnsSeen.size).toBeGreaterThanOrEqual(records.length / 4) + }) + it('settles what a gone generation left running after recovery resolution, before publishing', async () => { const calls: string[] = [] const params: AgentSessionAttachParams = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index d54687c25a6..4e4b7f1e5dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -11,6 +11,7 @@ // with no client attached and nothing on screen. A child now exists because work asked for it — a // send, through the delivery loop — not because a record survived on disk. +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' @@ -72,7 +73,9 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, ({ sessionId }) => - restoreOneStructuredAgentSessionRead(input, sessionId) - ) + await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { + // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. + await yieldToEventLoop() + await restoreOneStructuredAgentSessionRead(input, sessionId) + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 524d8943fac..35425a70afe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -13,7 +13,8 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -25,6 +26,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -57,7 +59,7 @@ function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner: async () => ({ @@ -90,8 +92,7 @@ function sendEnvelope( async function restartWithPersistedTurn(): Promise { root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) resetHostTestOperationIds() - const directory = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') @@ -102,7 +103,7 @@ async function restartWithPersistedTurn(): Promise { // Delivered, not just accepted: a message still queued at the restart was never a request. await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() - return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) + return createHost(await openTestAgentSessionRecordStore(root)) } afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index 8c3fdbf3aa7..d611bdb1589 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -57,12 +58,14 @@ it.each(['approval', 'question', 'completed'])( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 2 }, - { kind: 'status', text: 'Provider is requesting approval' } + { kind: 'status', text: 'Provider is requesting approval' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 3 }, @@ -73,7 +76,7 @@ it.each(['approval', 'question', 'completed'])( kind: event === 'approval' ? 'approval' : 'question' } : { kind: 'turn', turnId: 'working', state: 'completed' }, - { lifecycle: true } + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushAllStreamedEvents() const offered = await new AgentSessionRecoveryCapsule(root).list(NOW) @@ -98,7 +101,8 @@ it.each(['approval', 'question', 'completed'] as const)( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => { @@ -111,7 +115,8 @@ it.each(['approval', 'question', 'completed'] as const)( }, event === 'completed' ? { kind: 'turn', turnId: 'working', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event } + : { ...pendingApproval().body, question: 'Which action?', kind: event }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return true } @@ -133,7 +138,8 @@ it('marks a settled chat whose subagent was still running', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled', ordinal: 1 }, - { kind: 'turn', turnId: 'settled', state: 'completed' } + { kind: 'turn', turnId: 'settled', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) children = [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })] @@ -160,7 +166,8 @@ it('offers nothing for a chat whose child was not proven stopped', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts new file mode 100644 index 00000000000..a5015ca1b25 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -0,0 +1,524 @@ +// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that +// file, and copies nothing: the copy waits for the chat's first real read or write, which is +// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when +// every chat had its own file, and opens no file it does not restore. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import Database from '../../sqlite/sync-database' +import type * as SyncDatabaseModule from '../../sqlite/sync-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase, + readTestJournalRows +} from '../agent-session-journal/journal-host-database-test-support' +import { + journalDirectoryFor, + legacyJournalDatabaseFile +} from '../agent-session-journal/journal-paths' +import { + readJournalSessionEpoch, + type JournalStoredRow +} from '../agent-session-journal/journal-row-table' +import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ + readOnlyOpens: new Array(), + openReadOnly: new Set() +})) + +// Every read-only open is of a per-chat file: the host's own database opens read-write. +vi.mock('../../sqlite/sync-database', async (importOriginal) => { + const actual = await importOriginal() + class RecordingDatabase extends actual.default { + constructor(...args: ConstructorParameters) { + super(...args) + if (args[1]?.readonly) { + readOnlyOpens.push(String(args[0])) + openReadOnly.add(this) + } + } + + override close(): void { + openReadOnly.delete(this) + super.close() + } + } + return { ...actual, default: RecordingDatabase } +}) + +const WORKSPACE_ID = 'repo-1::/tmp/workspace' +const PROMPT = 'add a retry' + +let root: string +let clock = 1_000 + +function recordFor(sessionId: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease. + return { + schemaVersion: 2, + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [ + { + linkId: `codex-1-${sessionId}`, + handle: { provider: 'codex', threadId: `thread-${sessionId}` }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + createdAt: 1, + updatedAt: 2, + lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 } + } as unknown as AgentSessionRecord +} + +function identityFor(sessionId: string) { + const record = recordFor(sessionId) + return journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 }) + ) +} + +function legacyDirFor(sessionId: string): string { + return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) +} + +/** What the run before the upgrade left open in a chat, for its restore to settle. */ +type MidWork = 'running tool call' | 'unresolved send' + +/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ +async function seedLegacyChat( + sessionId: string, + replies = 1, + midWork?: MidWork +): Promise { + const scratch = join(root, `scratch-${sessionId}`) + const identity = identityFor(sessionId) + const journal = await openAgentSessionJournal({ + identity, + database: openTestJournalHostDatabase(scratch), + now: () => (clock += 1) + }) + await journal.appendSubmission({ + clientMessageId: `client-${sessionId}`, + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence: 1, + handoverRecorded: true + }) + if (midWork === 'running tool call') { + await journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal: 50 }, + { kind: 'tool-call', name: 'Read', input: {}, state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await journal.resolveDispatch( + midWork === 'unresolved send' + ? // Handed over, and never answered. + { + clientMessageId: `client-${sessionId}`, + fence: 1, + state: 'pending', + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + : { + clientMessageId: `client-${sessionId}`, + fence: 1, + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: `thread-${sessionId}`, + turnId: 't', + ordinal: 0 + } + } + ) + for (let ordinal = 1; ordinal <= replies; ordinal += 1) { + await journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await journal.close() + const rows = readTestJournalRows( + openTestJournalHostDatabase(scratch).db, + sessionId, + journal.epoch + ) + const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) + await mkdir(dirname(path), { recursive: true }) + const db = new Database(path) + try { + db.pragma('journal_mode = WAL') + db.exec(` +CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, + ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) + db.pragma('user_version = 2') + const insert = db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson) + } + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1) + } finally { + db.close() + } + return rows +} + +function legacyFile(sessionId: string): string { + return legacyJournalDatabaseFile(legacyDirFor(sessionId)) +} + +function hostDb(): Database.Database { + return openTestJournalHostDatabase(root).db +} + +function importCount(): number { + return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) +} + +type LifetimeHost = Parameters[0] + +function conversations(): StructuredAgentSessionConversations { + return new StructuredAgentSessionConversations({ + deliver: () => undefined, + onDeliveryError: () => undefined, + now: () => clock + }) +} + +async function restore(sessionIds: readonly string[]) { + const sessions = conversations() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. + const store = { + getRecord: (sessionId: string) => recordFor(sessionId), + listRecords: () => sessionIds.map(recordFor) + } as unknown as AgentSessionRecordStore + const deps = { store, adapter: {}, journalDatabase: openTestJournalHostDatabase(root) } + await restoreStructuredAgentSessionsOnRestart({ + openDeps: deps, + records: sessionIds.map(recordFor), + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: (sessionId) => sessions.has(sessionId), + onReadable: (sessionId, opened) => { + sessions.set(sessionId, opened.session) + } + }) + const context = (): StructuredAgentSessionLifetimeContext => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. + ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext + const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) => + createStructuredAgentSessionConversationLifetime({ + context, + sessions: listed, + serialize: async (_sessionId, task) => task(), + open, + deliveryActive: () => false, + closeStatus: () => undefined, + readChildWork: () => undefined + }) + const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) + return { sessions, lifetime, lifetimeOver } +} + +function texts(items: readonly { body: unknown }[]): string { + return JSON.stringify(items.map((entry) => entry.body)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-')) + clock = 1_000 + readOnlyOpens.length = 0 +}) + +afterEach(async () => { + vi.restoreAllMocks() + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('startup restore of chats still in their per-chat files', () => { + it('lists every restored chat without copying one, and opens only their files', async () => { + const restored = ['chat-a', 'chat-b', 'chat-c'] + const rows = new Map() + for (const sessionId of [...restored, 'chat-closed']) { + rows.set(sessionId, await seedLegacyChat(sessionId)) + } + readOnlyOpens.length = 0 + const before = new Map( + await Promise.all( + restored.map( + async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const + ) + ) + ) + + const { sessions } = await restore(restored) + + // Each file was closed by the fold that read it, so nothing holds it open for a later rename. + expect(openReadOnly.size).toBe(0) + // Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files. + for (const sessionId of restored) { + expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true) + const entries = await readdir(legacyDirFor(sessionId)) + expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([]) + } + + expect([...sessions.keys()].sort()).toEqual(restored) + for (const sessionId of restored) { + const journal = sessions.get(sessionId)!.journal + expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) + expect(texts(journal.snapshot().items)).toContain('reply 1') + expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() + expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) + } + for (const sessionId of restored) { + await sessions.get(sessionId)!.journal.close() + } + expect(importCount()).toBe(0) + // One read of each restored chat's file, and none of the chat that was not restored. + expect(readOnlyOpens.sort()).toEqual( + restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort() + ) + }) + + // Restore copies a chat only to write to it itself, settling what the last run left open (a + // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. + it.each(['running tool call', 'unresolved send'] as const)( + 'copies during restore only a chat it settles (%s)', + async (midWork) => { + const rows = await seedLegacyChat('chat-mid-work', 1, midWork) + await seedLegacyChat('chat-settled') + + const { sessions } = await restore(['chat-mid-work', 'chat-settled']) + + // Restore wrote a settlement to the chat left mid-work, so it copied that chat first. + const settled = sessions.get('chat-mid-work')!.journal + expect(settled.cursor().sequence).toBeGreaterThan(rows.length) + expect( + readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) + ).toEqual(rows) + expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) + expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull() + expect(existsSync(legacyFile('chat-settled'))).toBe(true) + expect(importCount()).toBe(1) + } + ) + + it('lets other work run while it reads a large per-chat file', async () => { + // Past one batch of the file's rows. + const rows = await seedLegacyChat('chat-a', 520) + let turns = 0 + let ticking = true + const tick = (): void => { + turns += 1 + if (ticking) { + setImmediate(tick) + } + } + + setImmediate(tick) + const folded = await previewPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: identityFor('chat-a'), + legacyDirectory: legacyDirFor('chat-a') + }) + ticking = false + + expect(turns).toBeGreaterThan(0) + expect(folded?.state.lastSequence).toBe(rows.length) + expect(openReadOnly.size).toBe(0) + }) + + // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. + it('offers the restorable turn of a chat it did not copy', async () => { + const rows = await seedLegacyChat('chat-a') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. + const marker = { + sessionId: 'chat-a', + journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length } + } as AgentSessionResumeMarker + + const { sessions } = await restore(['chat-a']) + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions, + now: () => clock, + enqueue: (operation) => operation() + }) + + expect(withdrawal.movedOn(marker)).toBe(false) + expect( + latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items) + ).toBe(PROMPT) + expect(importCount()).toBe(0) + }) + + it('copies a chat on its first read, and serves its history verbatim', async () => { + const rows = await seedLegacyChat('chat-a') + const { lifetime, sessions } = await restore(['chat-a']) + const listed = sessions.get('chat-a')!.journal.snapshot() + + const journal = (await lifetime.conversation('chat-a')).journal + const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) + + // The copied chat is the chat restore listed, item for item. + expect(journal.snapshot()).toEqual(listed) + + expect(importCount()).toBe(1) + expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) + expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) + expect(existsSync(legacyDirFor('chat-a'))).toBe(false) + }) + + // A read queued behind restore's open of the same chat reaches it through its own open, not the + // listing: it still waits for the copy, and reads the chat from the one database. + it('makes a read whose open lands on the chat restore opened wait for its copy', async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions, lifetimeOver } = await restore(['chat-a']) + const restored = sessions.get('chat-a')! + const lifetime = lifetimeOver(conversations(), async () => restored) + + const { journal } = await lifetime.conversation('chat-a') + const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) + + expect(importCount()).toBe(1) + expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) + }) + + // Whether the read finds the chat restore listed, or opens it and lands on the one restore opened. + it.each(['listed', 'opened'] as const)( + 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', + async (reach) => { + await seedLegacyChat('chat-a') + const { sessions, lifetime, lifetimeOver } = await restore(['chat-a']) + const restored = sessions.get('chat-a')! + await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) + await mkdir(legacyDirFor('chat-a'), { recursive: true }) + await writeFile(legacyFile('chat-a'), 'not a database, and never was one') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reader = + reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) + + const read = reader.conversation('chat-a') + + await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError) + await expect(read).rejects.toMatchObject({ + message: 'agent_session_journal_unreadable', + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + ) + + it('copies a chat before its first write, and the write lands after its history', async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + + await journal.appendItem( + { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch) + expect(stored.slice(0, rows.length)).toEqual(rows) + expect(stored).toHaveLength(rows.length + 1) + expect(importCount()).toBe(1) + }) + + it("copies a chat before its first queued message, which lands as that chat's draft", async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + expect(journal.importPending).toBe(true) + + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, + fingerprint: 'fp-draft-1', + hostInstance: 'proc-1' + }) + + expect(journal.importPending).toBe(false) + expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) + expect(existsSync(legacyFile('chat-a'))).toBe(false) + expect(importCount()).toBe(1) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + + it('never shows a read racing the copy a partly copied chat', async () => { + // Past one import batch, so the copy yields to other work between batches. + const rows = await seedLegacyChat('chat-a', 520) + const { lifetime, sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + const cursor = { epoch: rows[0]!.epoch, sequence: 0 } + const seen: { published: boolean; copied: number; folded: number }[] = [] + const reads: Promise[] = [] + let ticking = true + const tick = (): void => { + seen.push({ + published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null, + copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), + folded: journal.cursor().sequence + }) + reads.push( + lifetime.conversation('chat-a').then(({ journal: read }) => { + const since = read.readSince(cursor) + return since.ok ? since.rows.length : -1 + }) + ) + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + + await lifetime.conversation('chat-a') + ticking = false + await new Promise((resolve) => setImmediate(resolve)) + + // The copy was under way while other work ran, and none of it saw part of the chat. + expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true) + expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) + expect(seen.every((turn) => turn.folded === rows.length)).toBe(true) + expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length])) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index a596cc5c9ed..b34de39b74f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A Codex rewind whose outcome only the provider can prove, found on a chat at rest: nothing on // screen will start the agent that settles it, so the next send does, and its message is kept. @@ -7,7 +8,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -23,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -77,7 +80,7 @@ function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }), @@ -104,10 +107,7 @@ beforeEach(async () => { } })) directory = await mkdtemp(join(tmpdir(), 'orca-rewind-rest-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(directory) host = openHost() }) @@ -158,11 +158,12 @@ function sendParams(text: string) { async function interruptedRewindAtRest(): Promise { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const drop = { ...KEPT, turnId: 'drop' } - sink.appendItem(KEPT, hostTestMessage('verified history')) - sink.appendItem(drop, hostTestMessage('to be rewound')) + sink.appendItem(KEPT, hostTestMessage('verified history'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(drop, hostTestMessage('to be rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) - rewind.mockImplementation(async (input) => { - await input.onReverted?.() + rewind.mockImplementation(async () => { throw new Error('history unavailable') }) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch @@ -171,10 +172,7 @@ async function interruptedRewindAtRest(): Promise { ) expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(directory) host = openHost() } @@ -205,14 +203,11 @@ describe('an interrupted Codex rewind on a chat at rest (R16)', () => { describe('a rewind asked of a chat at rest (P2-23)', () => { it('starts the agent first and answers with what the provider says', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) - sink.appendItem(KEPT, hostTestMessage('kept')) + sink.appendItem(KEPT, hostTestMessage('kept'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(directory) host = openHost() const before = acquires rewindSupport.mockReturnValue({ supported: false, reason: 'history-not-paginated' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index a8ded60e6ff..35e7d5af2e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,7 +9,9 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter, @@ -25,6 +28,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -50,10 +54,7 @@ beforeEach(async () => { }) acquires = [] directory = await mkdtemp(join(tmpdir(), 'orca-rewind-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(directory) adapter = { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, @@ -93,7 +94,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) @@ -151,7 +152,7 @@ async function seed(acceptedSubmissions = false) { selectedItemId = agentJournalSubmissionKey(clientOperationId) } } else { - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } } await host.flushStreamedEvents(HOST_TEST_SESSION) @@ -204,16 +205,12 @@ describe('host rewind', () => { it('retries complete hydration after native acknowledgement without committing partial history', async () => { const target = await seed() const before = await host.journalSnapshot(HOST_TEST_SESSION) - rewind.mockImplementation(async (input) => { - await input.onReverted?.() + rewind.mockImplementation(async () => { throw new Error('history unavailable') }) await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) - expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ - phase: 'prepared', - providerApplied: true - }) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) await expect( host.attach( @@ -268,7 +265,8 @@ describe('host rewind', () => { const target = await seed() sink.appendItem( { provider: 'orca', clientMessageId: 'active' }, - { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } + { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: false, @@ -341,6 +339,42 @@ describe('host rewind', () => { expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) }) + // The journal is replaced only once the provider proves the revert, so both still hold the turn. + it('settles an acknowledged revert the provider did not keep, so the chat attaches and sends', async () => { + const target = await seed() + const before = await host.journalSnapshot(HOST_TEST_SESSION) + rewind.mockImplementationOnce(async () => { + throw new Error('history unavailable') + }) + await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') + expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) + recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') + const body = hostTestMessage('after the refused rewind') + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + }) + it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string) => ({ @@ -377,18 +411,29 @@ describe('host rewind', () => { completedAt: HOST_TEST_NOW - 4_000, durationMs: 5_000 } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(turnRow('kept'), keptTurn) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(turnRow('drop'), { ...keptTurn, turnId: 'drop', durationMs: 1_000 }) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(turnRow('kept'), keptTurn, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + turnRow('drop'), + { ...keptTurn, turnId: 'drop', durationMs: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) // The provider preflight knows only its own items, never the host's turn rows. const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] rewind.mockImplementationOnce(async (input) => { await input.onPrepared?.(items) - await input.onReverted?.() return { ok: true, items } }) @@ -411,6 +456,97 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string, ordinal = 0) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal + }) + const turnRow = (turnId: string) => ({ + provider: 'legacy' as const, + agent: 'codex', + sessionId: HOST_TEST_SESSION, + recordId: `turn-lifecycle:${turnId}` + }) + for (const turnId of ['kept', 'drop']) { + const body = hostTestMessage(turnId) + const clientOperationId = hostTestOperationId() + vi.mocked(adapter.dispatch).mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: message(turnId) + }) + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId, + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState + ).toBe('accepted') + ) + // As the translator writes it: the turn names the submission that opened it. + sink.appendItem( + turnRow(turnId), + { + kind: 'turn', + turnId, + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey(clientOperationId), + startedAt: HOST_TEST_NOW - 2_000, + completedAt: HOST_TEST_NOW - 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message(turnId, 1), + { ...hostTestMessage(`answer ${turnId}`), role: 'assistant' }, + { turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turnRow(turnId)) } } + ) + await host.flushStreamedEvents(HOST_TEST_SESSION) + } + const items = [ + { identity: message('kept'), body: hostTestMessage('kept') }, + { + identity: message('kept', 1), + body: { ...hostTestMessage('answer kept'), role: 'assistant' as const } + } + ] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + return { ok: true, items } + }) + const target = (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.at(-1)! + expect( + await host.rewind(caller, await params(agentJournalSubmissionKey(target.clientMessageId))) + ).toMatchObject({ ok: true }) + + const rebuilt = await host.journalSnapshot(HOST_TEST_SESSION) + const membership = nativeChatTurnMembership( + rebuilt.items.flatMap(({ itemId, body }) => + body.kind === 'message' ? [{ id: itemId, role: body.role }] : [] + ), + rebuilt + ) + const opener = agentJournalItemKey(message('kept')) + expect(membership.turnKeys).toEqual([opener, opener]) + }) + it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string) => ({ @@ -432,13 +568,20 @@ describe('host rewind', () => { payload: { head: '{}', byteLength: 2, digest: '4'.repeat(64), truncated: false } } } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) - rewind.mockImplementationOnce(async (input) => { - await input.onReverted?.() + rewind.mockImplementationOnce(async () => { throw new Error('lost after provider revert') }) @@ -476,7 +619,6 @@ describe('host rewind', () => { })) rewind.mockImplementationOnce(async (input) => { await input.onPrepared?.(items) - await input.onReverted?.() throw new Error('lost after revert') }) await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 0f85041bcc4..6358cdfe0f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -1,4 +1,5 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { agentJournalItemKey, agentJournalSubmissionKey, @@ -11,6 +12,7 @@ import type { AgentSessionRewindResult } from '../../../shared/agent-session-rewind' import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { openWithAgent } from './structured-agent-session-send-preparation' @@ -120,10 +122,12 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt }) => ({ + .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ itemId: providerKey(itemId), - body, - observedAt + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) })) if ( retained.length > 10_000 || @@ -165,12 +169,6 @@ export async function rewindStructuredAgentSession( } prepared = { ...prepared, retained } await persistRewindRecord(store, sessionId, ctx.fence, prepared) - }, - onReverted: async () => { - await persistRewindRecord(store, sessionId, ctx.fence, { - ...prepared, - providerApplied: true - }) } }) const fence = store.getRecord(sessionId)!.lease.runtimeFence @@ -229,3 +227,18 @@ export async function rewindStructuredAgentSession( : result }) } + +/** The new epoch keeps no submissions, so a sent message survives only under its provider key; the + * turn it opened must name it by that key too, or the turn anchors on nothing. */ +function withRenamedTurnOpener( + body: AgentJournalItemBody, + rename: (itemId: string) => string +): AgentJournalItemBody { + if (body.kind === 'turn' && body.userItemId !== undefined) { + return { ...body, userItemId: rename(body.userItemId) } + } + const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined + return body.kind === 'status' && lifecycle?.userItemId !== undefined + ? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } } + : body +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 79882025889..61696b573c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -13,22 +13,19 @@ function withCommand(command: AgentSessionConversationCommandRecord) { const COMMAND = { operationId: 'operation-1', callerKey: 'client-1', runtimeFence: 7 } describe('a send refused by the conversation command it follows', () => { - it('says a /clear that never committed did not finish, not that the chat was cleared', () => { - const blocked = structuredAgentSessionSendBlock( - withCommand({ - ...COMMAND, - command: 'clear', - state: 'unknown', - phase: 'prepared', - replacementSessionId: 'clear-replacement-1' - }) - ) - - expect(blocked?.refusal).toMatchObject({ - code: 'agent_session_operation_invalid', - details: { reason: 'clearUnconfirmed' }, - message: "The last /clear didn't finish. Start a new chat to continue." - }) + // A clear's commit is its only durable write, so a record short of it never changed the chat. + it("lets a send follow a /clear that never committed, as an older build's record leaves one", () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'unknown', + phase: 'prepared', + replacementSessionId: 'clear-replacement-1' + }) + ) + ).toBeNull() }) it('says a committed /clear cleared the conversation', () => { @@ -48,14 +45,12 @@ describe('a send refused by the conversation command it follows', () => { }) }) - it('keeps an unconfirmed /compact as an unconfirmed command', () => { - const blocked = structuredAgentSessionSendBlock( - withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) - ) - - expect(blocked?.refusal).toMatchObject({ - details: { reason: 'conversationCommandUnconfirmed' } - }) + it("lets a send follow an older build's unconfirmed /compact, whose child this host no longer runs", () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) + ) + ).toBeNull() }) it('lets a send follow a /clear whose new conversation failed to start', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 2494535e019..1925c13378e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -25,7 +25,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: root + stateDirectory: root }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 0a14f436119..c85c628d917 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -1,14 +1,14 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A send can be what opens a conversation this process has not read yet: a chat nobody has on // screen after the app died, sent to from a phone or the CLI. Whatever that journal shows running // belongs to a generation that is gone, so it is settled when the journal opens, not only when a // new child starts: a start that then fails would leave the turn running for every reader. import { cp, rm } from 'node:fs/promises' -import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { adapter, @@ -24,6 +24,7 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -51,7 +52,8 @@ async function relaunchAfterCrashMidTurn( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'crashed-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'crashed-turn', state: 'running' } + { kind: 'turn', turnId: 'crashed-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await dying.host.flushStreamedEvents(SESSION) // An empty renewal queues behind every record write, so they are on disk. @@ -63,17 +65,14 @@ async function relaunchAfterCrashMidTurn( recursive: true, filter: (source) => !source.includes('.lock') }) - const store = await AgentSessionRecordStore.open({ - directory: join(relaunched, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(relaunched) const acquire = vi.fn(async () => { throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), acquire }, - journalRoot: relaunched, + journalDatabase: openTestJournalHostDatabase(relaunched), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', probeOwner: async () => probe, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 259ad16c858..fbe81be21ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -10,7 +10,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' @@ -23,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -67,7 +69,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -79,7 +81,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index a7a0e971055..be29089991b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -1,8 +1,8 @@ // What a send or a Stop needs from the session before the ledger places its row: the // conversation open. Nothing here needs an owner — a send is accepted into the conversation and // the delivery loop makes the session ready — so a refusal before acceptance is only one the -// conversation itself makes: a rewind or conversation command in doubt, a cleared conversation, -// or a journal that cannot be opened. +// conversation itself makes: a rewind in doubt, a cleared conversation, or a journal that cannot be +// opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -12,11 +12,11 @@ import { } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import { - classifyJournalOpenFailure, - type JournalOpenFailure -} from '../agent-session-journal/journal-open-failure' + structuredAgentSessionAwaitedCommand, + type StructuredAgentSessionAwaitedCommandJournal +} from './structured-agent-session-command-turn' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -34,32 +34,20 @@ export function structuredAgentSessionSendBlock( return rewindRefusal('outcome-unknown') } const command = record?.conversationCommand + // Only a committed clear: one that never committed changed nothing, and an older build's + // unconfirmed record is one of those. if ( - command && - ((command.state === 'unknown' && command.phase === 'prepared') || - (command.command === 'clear' && command.replacementSessionId)) + command?.command === 'clear' && + command.phase === 'committed' && + command.replacementSessionId ) { return { ok: false, - refusal: - command.phase === 'committed' && command.replacementSessionId - ? refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCleared' }, - 'This conversation has been cleared. Use the current conversation.' - ) - : // A prepared /clear names its replacement before that conversation exists. - command.command === 'clear' - ? refuse( - 'agent_session_operation_invalid', - { reason: 'clearUnconfirmed' }, - "The last /clear didn't finish. Start a new chat to continue." - ) - : refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCommandUnconfirmed' }, - 'The conversation operation is unconfirmed.' - ) + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCleared' }, + 'This conversation has been cleared. Use the current conversation.' + ) } } return null @@ -77,20 +65,10 @@ export async function openConversationForWrite( return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { console.warn('[agent-session] opening the conversation for a write failed:', error) - const reason = classifyJournalOpenFailure(error) - return { - ok: false, - refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) - } + return { ok: false, refusal: journalOpenRefusal(error) } } } -// Released clients print a refusal's message for a send; it fits a Stop too. -const JOURNAL_OPEN_MESSAGE: Record = { - journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), - journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) -} - /** The conversation a write lands in, opened when this host holds it closed. */ export function openForWrite( context: Pick, @@ -125,9 +103,15 @@ export function sendPreparation( } } -/** Who a failure sentence names: the chat's agent, when the record says. */ +/** Who a failure sentence names: the chat's agent, when the record says; and, given the journal, + * the command a failed start leaves to run again. */ export function structuredAgentSessionFailureWordsContext( - record: AgentSessionRecord | null + record: AgentSessionRecord | null, + journal?: StructuredAgentSessionAwaitedCommandJournal ): AgentSessionFailureWordsContext { - return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} + const command = journal && structuredAgentSessionAwaitedCommand(journal) + return { + ...(record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {}), + ...(command ? { command } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index f339aa1113f..21e50e4dca6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -14,7 +14,8 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -27,6 +28,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -142,7 +144,7 @@ beforeEach(async () => { providerChildPhase: 'starting' as const })) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -154,7 +156,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index ff9765d1cee..677963ab3f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -27,6 +27,74 @@ function emptyJournal(): AgentSessionJournal { } as unknown as AgentSessionJournal } +/** A message accepted and not yet handed over, while `activeTurnId` names the running turn. */ +function queuedJournal( + activeTurnId: string | null, + handedOver = false +): Pick { + return { + cursor: () => ({ epoch: 'epoch-1', sequence: handedOver ? 3 : 2 }), + activeTurnId: () => activeTurnId, + submissions: () => [ + { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true, + ...(handedOver ? { handedOverAt: 3 } : {}) + } + ] + } +} + +describe('a wait that also ends behind a running command', () => { + const until = 'handed-over-or-behind-command' as const + + it('ends once the message waits behind a running command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal('turn-1')) + const pending = settlements.wait('session-1', 'client-1', { until }) + + settlements.publish('session-1', queuedJournal('compact:cmd-1')) + + const settled = await pending + if (!settled || !('submission' in settled.value)) { + throw new Error('expected the submission arm') + } + expect(settled.value.submission.dispatchState).toBe('pending') + expect(settled.value.submission).not.toHaveProperty('handedOverAt') + }) + + it('keeps waiting for the handover behind anything that is not a command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal(null)) + let settled = false + const pending = settlements.wait('session-1', 'client-1', { until }).then((result) => { + settled = true + return result + }) + + settlements.publish('session-1', queuedJournal('turn-1')) + await Promise.resolve() + expect(settled).toBe(false) + settlements.publish('session-1', queuedJournal('turn-1', true)) + + await expect(pending).resolves.toMatchObject({ value: { submission: { handedOverAt: 3 } } }) + }) + + it('leaves the plain handover wait to the handover', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => + queuedJournal('compact:cmd-1') + ) + const pending = settlements.wait('session-1', 'client-1', { until: 'handed-over', budgetMs: 1 }) + + await expect(pending).resolves.toBeUndefined() + }) +}) + describe('structured send settlement compatibility wait', () => { afterEach(() => vi.useRealTimers()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index bc265833c42..a9815b953f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -5,9 +5,10 @@ import type { import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isStructuredAgentSessionCommandTurnId } from './structured-agent-session-command-turn' /** What a settlement read needs of a journal. */ -type SendSettlementJournal = Pick +type SendSettlementJournal = Pick type SettledSend = { cursor: AgentJournalCursor @@ -16,8 +17,9 @@ type SettledSend = { type SendSettlement = SettledSend | 'pending' | 'missing' -/** What ends a wait: the provider's answer, or only the host handing the message over. */ -export type SendSettlementPoint = 'answered' | 'handed-over' +/** What ends a wait: the provider's answer, the host handing the message over, or either that or + * the message waiting behind a running command, which hands nothing over until it ends. */ +export type SendSettlementPoint = 'answered' | 'handed-over' | 'handed-over-or-behind-command' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -55,12 +57,18 @@ function settledSend( return 'missing' } const waiting = - until === 'handed-over' - ? isQueuedAgentJournalSubmission(submission) - : submission.dispatchState === 'pending' + until === 'answered' + ? submission.dispatchState === 'pending' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal)) return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } +function runningCommand(journal: SendSettlementJournal): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index d2b9d27c1a0..f0163b22791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -67,6 +67,9 @@ describe('send', () => { if (!result.ok) { throw new Error(`expected a send, got ${result.refusal.code}`) } + if (!('submission' in result.value)) { + throw new Error('expected the submission arm') + } // Answered once accepted; the delivery loop hands it over after. expect(result.value.submission).toMatchObject({ dispatchState: 'pending', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 7602afa230b..2d868797024 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type * as DurableFileWrite from '../../durable-file-write' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -24,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -108,11 +110,11 @@ beforeEach(async () => { })) releaseAcquisition = vi.fn(async () => true) dispatch = vi.fn(async () => accepted()) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -139,7 +141,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), historyFilePath }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -196,7 +198,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, now: () => NOW @@ -240,7 +242,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, // A host that cannot read another process's environment, so no scan can prove anything. @@ -266,11 +268,11 @@ describe('settled attach retry', () => { }) await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, // A host that cannot read another process's environment, so no scan can prove anything. @@ -323,11 +325,11 @@ describe('settled attach retry', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-restarted', probeOwner: async () => ({ outcome: 'pid-absent' }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 83959233f6f..204da81a2e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -3,6 +3,7 @@ // reading it before the restart restore reaches it. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { @@ -38,7 +39,8 @@ async function exitWithUnwrittenSettlement(refusedWrites = 2): Promise { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) const append = vi.spyOn(open.journal, 'appendLifecycleBatch') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 2c7a91e7637..71c5df2692d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -1,13 +1,18 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { runningTurnLifecycleRevisions, @@ -23,6 +28,10 @@ const RUNNING_IDENTITY = { ordinal: 0 } +function turnScopeOf(identity: AgentJournalItemIdentity): AgentJournalTurnScope { + return { kind: 'turn', turnItemId: agentJournalItemKey(identity) } +} + function lifecycleItem( turnId: string, state: 'running' | 'completed', @@ -169,7 +178,8 @@ describe('running turn lifecycle revisions', () => { state: 'interrupted', startedAt: 30, completedAt: 40 - } + }, + turnScope: { kind: 'thread' } } ] ) @@ -238,7 +248,8 @@ describe('running turn lifecycle revisions', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ]) }) @@ -295,7 +306,8 @@ describe('stale session state on a cold acquire', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ] }) @@ -337,7 +349,8 @@ describe('stale session state on a cold acquire', () => { resolvedBy: null, resolvedAt: null } - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -356,7 +369,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) const child = { agentId: 'thread-child', producerKind: 'agent' as const } @@ -367,8 +380,15 @@ describe('stale session state on a cold acquire', () => { turnId: 'turn-1', ordinal: 1 }) - await journal.appendItem(prompt('thread-child'), body, { fence: 1, ...child }) - await journal.appendItem(prompt(THREAD), body, { fence: 1 }) + await journal.appendItem(prompt('thread-child'), body, { + fence: 1, + ...child, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(prompt(THREAD), body, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await settleStaleStructuredAgentSessionState({ journal, @@ -403,24 +423,25 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) const command = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } const shell = { kind: 'tool-call' as const, name: 'shell', input: { command: 'pnpm test' } } + const turnScope = turnScopeOf({ ...command, ordinal: 0 }) await journal.appendItem( { ...command, ordinal: 0 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope } ) now = 200 - await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1 }) + await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1, turnScope }) // Rows can outlast the last renewal; only the renewal is proof of life. now = 700 await journal.appendItem( command, { ...shell, input: { command: 'pnpm test', streamed: 'ok' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope } ) now = 9_000 @@ -468,19 +489,19 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) await journal.appendItem( RUNNING_IDENTITY, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) now = 200 await journal.appendItem( { ...RUNNING_IDENTITY, ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) const settle = (deathEvidence: AgentSessionDeathEvidence | null) => settleStaleStructuredAgentSessionState({ @@ -503,7 +524,12 @@ describe('stale session state on a cold acquire', () => { fence: 1, handoverRecorded: true }) - await journal.resolveDispatch({ clientMessageId: 'send-1', state: 'pending', fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + fence: 1 + }) now = 3_606_000 await settle({ @@ -534,13 +560,13 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) await journal.appendItem( RUNNING_IDENTITY, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) now = 400 // The open, before anything proved the fence-1 owner gone. @@ -594,7 +620,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) // Enough running turns that the settle writes two batches, and its retry two again. @@ -603,7 +629,7 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( { ...RUNNING_IDENTITY, turnId }, { kind: 'turn', turnId, state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf({ ...RUNNING_IDENTITY, turnId }) } ) } now = 9_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index b23018aeba8..97c951b4517 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -7,9 +7,10 @@ // with no end at all, until a proof naming its owner is written and revises it. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { agentJournalTurnBody, @@ -95,7 +96,14 @@ function turnLifecycleRevision( ): JournalLifecycleMutationInput[] { const identity = parseAgentJournalItemKey(item.itemId) return identity - ? [{ kind: 'item', identity, body: agentJournalTurnBody(settledLifecycle(turn, verdict)) }] + ? [ + { + kind: 'item', + identity, + body: agentJournalTurnBody(settledLifecycle(turn, verdict)), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] : [] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 77ba7fe5f58..12fa05710f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,4 +1,5 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' @@ -24,7 +25,9 @@ export function structuredAgentSessionStartFailureRow( kind: 'item', identity: structuredAgentSessionStartFailureRowIdentity(startKey), // The row repeats the sentence the start's rejected messages carry. - body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }, + // A start that failed opened no turn. + turnScope: AGENT_JOURNAL_THREAD_SCOPE } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 5ce1fdb3694..e5527d1a5af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -14,7 +14,8 @@ import { import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -25,6 +26,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -116,7 +118,7 @@ beforeEach(async () => { awaitStarted = vi.fn( () => new Promise((resolve) => (settleStart = resolve)) ) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -140,7 +142,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index dae8635cd69..3c4096be138 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -4,6 +4,7 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -22,6 +23,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), @@ -140,4 +142,30 @@ describe('a provider that ends before it finished starting', () => { }) ) }) + + it('names /compact as the next step when the start that failed was carrying it', async () => { + const base = startedSession() + const session = { + ...base, + child: { generation: GENERATION, fence: 7, phase: 'starting' as const }, + journal: { + ...base.journal, + submissions: () => [{ clientMessageId: 'compact-1', dispatchState: 'pending' as const }], + itemBody: () => structuredAgentSessionCompactBody() + } + } + + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + + const text = 'Claude stopped before it finished starting. Run /compact again.' + expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: text }) + ) + expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ + mutations: [expect.objectContaining({ body: expect.objectContaining({ text }) })] + }) + ) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index 1731674baad..03c490742ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusSink @@ -34,7 +34,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const session = indexedStatusFeedSession({ journal }) return new StructuredAgentSessionStatusFeed({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index 78e10bec076..545a0ebb460 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -29,7 +29,7 @@ it('publishes each provider child even when a replacement has the same startup p agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: root }) const sessions = new Map>() const setChild = ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 1a69132e03f..08dd33ec116 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { AgentSessionStatusEvent, @@ -12,7 +13,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusSink @@ -61,7 +62,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const views: { current: () => AgentChildWorkView[] } = { current: () => [] } const admitted: AgentChildWorkEvidence[][] = [] @@ -85,7 +86,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { journal, feed, events, views, admitted, changed } } @@ -103,7 +104,7 @@ describe('structured status summary child records', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -136,7 +137,7 @@ describe('structured status summary child records', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) const before = events.length @@ -272,7 +273,7 @@ describe('structured status summary child records', () => { journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }, { kind: 'turn', turnId, state }, - { fence: 1, ...(agentId ? { agentId } : {}) } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(agentId ? { agentId } : {}) } ) const retirements = () => admitted.flat().filter((edge) => edge.type === 'turn-started') // The session was first projected before it had a turn, so its first turn is a new one. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index dcd06c0c7ef..c3922852245 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Which journal changes republish a session's status now that the summary carries its own state // clock: a moved clock always does, and row activity alone does not once the clock dates the state. @@ -10,7 +11,7 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -56,7 +57,7 @@ async function openFeed() { providerHandle: { kind: 'codex', threadId: THREAD } }, now: () => (clock += 100), - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), @@ -73,7 +74,7 @@ async function openFeed() { } }) const write = async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - await journal.appendItem(identity, body, { fence: 1 }) + await journal.appendItem(identity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) feed.publish(SESSION, journal) } await write( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 78e23e64496..8062b740bcb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,7 +13,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { indexedStatusFeedSession as indexed, statusFeedChildView @@ -61,7 +62,7 @@ async function openJournal(sessionId = SESSION, now?: () => number) { providerHandle: { kind: 'codex', threadId: 'thread-1' } }, now, - journalDir: join(root, sessionId) + stateDirectory: join(root, sessionId) }) } @@ -111,7 +112,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -221,12 +222,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -257,12 +258,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 @@ -288,20 +289,20 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const assistant = { ...USER_IDENTITY, ordinal: 2 } await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ @@ -325,12 +326,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) for (let revision = 1; revision <= 20; revision += 1) { @@ -338,7 +339,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) } @@ -362,12 +363,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -378,7 +379,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( { ...USER_IDENTITY, ordinal: 2 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -396,7 +397,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, @@ -407,7 +408,7 @@ describe('StructuredAgentSessionStatusFeed', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -424,7 +425,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -455,7 +456,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const late: AgentSessionStatusEvent[] = [] @@ -487,7 +488,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -506,12 +507,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix the auth bug' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) @@ -539,7 +540,7 @@ describe('StructuredAgentSessionStatusFeed', () => { role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const seen: (string | null)[] = [] const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => @@ -619,7 +620,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(() => feed.publish(SESSION, journal)).not.toThrow() @@ -634,12 +635,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const snapshot = vi.spyOn(journal, 'snapshot') let taskState: 'working' | 'waiting' = 'working' @@ -667,7 +668,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const record = { options: { model: 'first-model' }, providerHandleChain: [] } const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) @@ -713,7 +714,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) // A second identical publication is deduped for the sink exactly as for subscribers, so the @@ -775,7 +776,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(() => feed.forget(SESSION)).not.toThrow() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index ae4615d021d..fff0ae2383b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -5,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentHookServer } from '../../agent-hooks/server' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { @@ -35,12 +36,12 @@ async function openJournal(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 5b3a0a6d9f4..dafe54e1790 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A subagent's work must not re-date the session that spawned it. // // The status row takes its completion stamp and acknowledgement clock from the summary's @@ -15,7 +16,7 @@ import { projectStructuredAgentSessionStatusSummary } from '../../../shared/stru import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -54,7 +55,7 @@ async function openSession() { providerHandle: { kind: 'codex', threadId: CODEX_THREAD } }, now: tick, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) // The host's status row and child records, which the feed writes into and reads back. const server = new AgentHookServer() @@ -90,7 +91,7 @@ async function openSession() { journal.appendItem( { provider: 'orca', clientMessageId }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const latestStatus = () => { const event = events.findLast((candidate) => candidate.type === 'status') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 82f8c551642..cf31d1d47f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent @@ -11,11 +14,12 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow +} from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -44,7 +48,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'activity-churn-journal') + stateDirectory: join(root, 'activity-churn-journal') }) const subscribers = new AgentSessionSubscribers() @@ -64,7 +68,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'checkpoint-journal') + stateDirectory: join(root, 'checkpoint-journal') }) const events: AgentSessionSubscribeEvent[] = [] @@ -103,7 +107,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'clock-journal') + stateDirectory: join(root, 'clock-journal') }) let now = 1_000 const events: AgentSessionSubscribeEvent[] = [] @@ -116,7 +120,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'clocked' }, { kind: 'status', text: 'Clocked' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) subscribers.backgroundTasks(SESSION, null, 1) @@ -148,7 +152,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'catalog-journal') + stateDirectory: join(root, 'catalog-journal') }) let commands = [{ name: 'first', kind: 'skill' as const }] const events: AgentSessionSubscribeEvent[] = [] @@ -191,7 +195,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'hook-journal') + stateDirectory: join(root, 'hook-journal') }) const published: string[] = [] const subscribers = new AgentSessionSubscribers({ @@ -220,7 +224,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'unread-journal') + stateDirectory: join(root, 'unread-journal') }) const statusFeed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ @@ -253,12 +257,12 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { ...turn, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( turn, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) @@ -285,7 +289,7 @@ describe('AgentSessionSubscribers', () => { agent: 'claude', providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } }, - journalDir: join(root, 'background-journal') + stateDirectory: join(root, 'background-journal') }) const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] @@ -318,7 +322,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'after-background-fence' }, { kind: 'status', text: 'After background state' }, - { fence: 2 } + { fence: 2, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) @@ -334,7 +338,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'activity-journal') + stateDirectory: join(root, 'activity-journal') }) const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] @@ -386,7 +390,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir + stateDirectory: journalDir }) // A row admitted before identity bounding: its removal id alone exceeds // the outbound cap, so no catch-up batch can ever carry it. @@ -419,15 +423,15 @@ describe('AgentSessionSubscribers', () => { // Staged straight into the session database, exactly as a previous writer // would have committed them. await seeded.close() - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) + const opened = openTestJournalHostDatabase(journalDir) try { opened.db.exec('BEGIN IMMEDIATE') for (const row of rows) { - insertJournalRow(opened.db, SESSION, row) + insertTestJournalRow(opened.db, SESSION, row) } opened.db.exec('COMMIT') } finally { - opened.db.close() + opened.close() } const journal = await journals.open({ identity: { @@ -437,7 +441,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir + stateDirectory: journalDir }) const subscribers = new AgentSessionSubscribers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index f600d6dd9bb..ae795674564 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -8,20 +8,18 @@ import type { AgentJournalCursor, AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import { - AGENT_SESSION_HISTORY_MAX_LIMIT, - type AgentSessionBackgroundTaskState, - type AgentSessionSlashCommand, - type AgentSessionSubscribeEvent, - type AgentSessionTurnActivity +import type { + AgentSessionBackgroundTaskState, + AgentSessionSlashCommand, + AgentSessionSubscribeEvent, + AgentSessionTurnActivity } from '../../../shared/agent-session-wire' -import { sameJournalCursor } from '../agent-session-journal/journal-cursor' +import { buildSubscriberFrame } from './agent-session-subscriber-frame-fields' +import type { QueuePublication } from './structured-agent-session-queued-publication' +import { deliverToSubscriber } from './agent-session-subscriber-catch-up' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' -import { - createAgentSessionCatchUpReader, - readAgentSessionHydrationPage -} from './agent-session-history-page' +import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void @@ -32,17 +30,23 @@ export type AgentSessionSubscribeInput = { cursor?: AgentJournalCursor } -type Subscriber = { +export type Subscriber = { id: string sessionId: string emit: AgentSessionSubscriberEmit cursor: AgentJournalCursor fence: number commands?: AgentSessionSlashCommand[] | null + /** The last draft list actually SENT — never advanced on a page that withheld + * it, or the final replacement would be suppressed by the identity dedup. */ + queuePublication?: QueuePublication } export type AgentSessionSubscribersHooks = { readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + /** Revision-stable per emit: an unchanged list keeps its reference, so token + * streams never re-serialize it; any draft-table write changes it. */ + readQueuePublication?: (sessionId: string) => QueuePublication | undefined /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void now?: () => number @@ -214,86 +218,15 @@ export class AgentSessionSubscribers { backgroundTasks?: AgentSessionBackgroundTaskState | null, activity?: AgentSessionTurnActivity | null ): void { - const checkpointActivity = emitCheckpoint - ? this.activityField(subscriber.sessionId).activity - : undefined - const publishedActivity = activity !== undefined ? activity : checkpointActivity - // Caught up, so there are no rows to read: every publish behind a commit's own delivery. - if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { - this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) - return - } - const readPage = createAgentSessionCatchUpReader(journal) - while (true) { - const result = readPage({ - sessionId: subscriber.sessionId, - direction: 'after', - cursor: subscriber.cursor, - limit: AGENT_SESSION_HISTORY_MAX_LIMIT - }) - if (!result.ok) { - const page = { ...result.page, fence: subscriber.fence } - this.emit(subscriber, { - type: 'reset', - sessionId: subscriber.sessionId, - reset: result.reset, - page, - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - subscriber.cursor = page.liveCursor ?? page.window.nextCursor - return - } - const page = result.page - const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence - if (!advanced) { - this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) - return - } - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: { - cursor: page.window.nextCursor, - items: page.items, - removedItemIds: page.removedItemIds, - submissions: page.submissions - }, - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - subscriber.cursor = page.window.nextCursor - if (!page.hasNewer || !this.isActive(subscriber)) { - return - } - } - } - - private emitCaughtUp( - subscriber: Subscriber, - hostNow: number, - emitCheckpoint: boolean, - backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, - activity: AgentSessionTurnActivity | null | undefined - ): void { - const commandsChanged = - this.hooks.readCommands !== undefined && - (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (emitCheckpoint || activity !== undefined || commandsChanged) { - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: emptyAgentSessionBatch(subscriber.cursor), - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(activity !== undefined ? { activity } : {}) - }) - } + deliverToSubscriber( + { + hooks: this.hooks, + emit: (target, event, options) => this.emit(target, event, options), + isActive: (target) => this.isActive(target), + activity: (sessionId) => this.activityField(sessionId).activity + }, + { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } + ) } private now = (): number => this.hooks.now?.() ?? Date.now() @@ -303,15 +236,23 @@ export class AgentSessionSubscribers { /** A dead transport cannot be allowed to turn a durable mutation into an * unknown outcome or poison every later publication. */ - private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + private emit( + subscriber: Subscriber, + event: AgentSessionSubscribeEvent, + options?: { withholdQueued?: boolean } + ): void { try { - const commands = this.hooks.readCommands?.(subscriber.sessionId) ?? null - const includeCommands = - this.hooks.readCommands !== undefined && - event.type !== 'end' && - (event.type !== 'batch' || commands !== subscriber.commands) - subscriber.emit(includeCommands ? { ...event, commands: commands ?? null } : event) - subscriber.commands = commands + const built = buildSubscriberFrame( + this.hooks, + subscriber, + event, + options?.withholdQueued === true + ) + subscriber.emit(built.frame) + subscriber.commands = built.commands + if (built.attachedQueued) { + subscriber.queuePublication = built.queued + } } catch { this.drop(subscriber) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index b04a9b3d820..a06c70bf30a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The lifetime of a provider child, against the real host rather than a double. // // Two leaks meet here: a chat that closes without stopping its app-server, and a launch that @@ -17,7 +18,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionAcquisitionRootExitObservedError, type StructuredAgentSessionAdapter @@ -38,6 +40,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -75,7 +78,7 @@ function openHost( host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, @@ -89,7 +92,7 @@ function openHost( /** A fresh app generation over the same durable store, with its owner proven gone. */ async function reboot(): Promise { await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) openHost(async () => ({ outcome: 'pid-absent' })) acquire.mockClear() closeSession.mockClear() @@ -121,7 +124,8 @@ function envelope(method: string, fields: Record): AgentSession function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } } + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -157,7 +161,8 @@ async function failJournalSinkUntilReleased(): Promise { vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -227,7 +232,7 @@ beforeEach(async () => { surface: 'rejection' }) })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) openHost() }) @@ -396,7 +401,7 @@ describe('startup', () => { const beforeRestart = store.getRecord(SESSION) await abandonStructuredAgentSessionHost(host) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) openHost(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() @@ -469,7 +474,8 @@ describe('a session closed and started again', () => { }) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink?.publish() await host.flushStreamedEvents(SESSION) @@ -797,7 +803,8 @@ describe('an unexpected provider exit', () => { expect(statuses).toContainEqual({ kind: 'status', text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' }) expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index f490eb2e9cd..c9cdaa997b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { journalRecordsThreadGoalChange, @@ -37,7 +38,7 @@ afterEach(async () => { async function openJournal(): Promise { root ??= await mkdtemp(join(tmpdir(), 'orca-thread-goal-')) - return journals.open({ identity: IDENTITY, journalDir: root }) + return journals.open({ identity: IDENTITY, stateDirectory: root }) } const GOAL: AgentJournalThreadGoal = { @@ -57,7 +58,7 @@ function appendGoalRow( return journal.appendItem( { provider: 'orca', clientMessageId: `goal-row:${journal.snapshot().items.length}` }, { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: { ...GOAL, ...overrides } } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 639daf6ab30..a779ba51abe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -70,7 +70,8 @@ export async function performThreadGoalChange( blocks: [{ type: 'text', text: change.objective }], sentAs: 'goal' }, - { fence: ctx.fence } + // Accepting a goal is delivering it, so it joins whatever turn runs now. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } const withdrawObjective = async (): Promise => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index 93d4a54cdc9..1c2ff9b9e53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -344,6 +344,39 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.feed.observe('session-unknown') expect(emit).not.toHaveBeenCalled() }) + + it('announces no /compact turn and keeps its mark on the last real turn (B6)', () => { + const h = harness() + h.listen() + const real = [userEntry('m1', 1), turnItem(turn('t1', 'completed', 'success'), 2)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(real, [accepted]) + h.observe() + const command: AgentJournalRenderItem = { + ...userEntry('c1', 3), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + } + const commandTurn = (lifecycle: AgentJournalTurnLifecycle): AgentJournalRenderItem => ({ + ...turnItem({ ...lifecycle, userItemId: command.itemId }, 4), + itemId: 'orca:command-turn:c1' + }) + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'running'))], + [accepted, pending('c1')] + ) + h.observe() + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'completed', 'success'))], + [accepted, sent('c1', { dispatchState: 'accepted' })] + ) + h.observe() + expect(h.events).toEqual([]) + }) }) describe('a request the agent or its start refused', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts new file mode 100644 index 00000000000..eb3784b0a1d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -0,0 +1,143 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' +import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import { + isStructuredAgentSessionCommandTurnId, + structuredAgentSessionCommandWasStopped, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +/** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. + * A failed drain reads working: bookkeeping never talks a Stop out of stopping. */ +export async function isMainAgentWorkingOnceFlushed( + ctx: Pick +): Promise { + try { + await ctx.flushStreamedEvents() + } catch { + return true + } + return isStructuredAgentSessionMainAgentWorking( + ctx.journal.activeTurnId(), + ctx.journal.submissions(), + ctx.fence + ) +} + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on. */ + stopChild?: () => Promise + /** The host already withdrew queued messages for this Stop. */ + withdrewQueued?: boolean + } +): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } + let cancelled = false + let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } + // The turn the Stop names, read before the cancel settles it: the note reports on that turn. + const turnScope = ctx.journal.liveTurnScope() + // Only the provider's end or the child's ends a command. A command the provider has not opened a + // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's + // dead-generation settlement writes the command's verdict. + const liveTurnId = ctx.journal.activeTurnId() + const runningCommand = + input.stopChild !== undefined && + liveTurnId !== null && + isStructuredAgentSessionCommandTurnId(liveTurnId) && + (input.turnId === undefined || input.turnId === liveTurnId) + const stoppedBefore = + runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) + try { + const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) + const outcome: AgentSessionCancelOutcome = stoppedBefore + ? { cancelled: false } + : input.scope + ? { + cancelled: + ( + await ctx.adapter.stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) + }) + )?.cancelled === true + } + : await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + fence: ctx.fence, + // The journal is what the client read to name a turn, so it is what judges the request. + resolveLiveTurnId: () => ctx.journal.activeTurnId(), + ...(dispatchStatus ? { dispatchStatus } : {}), + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) + }) + cancelled = outcome.cancelled + if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { + // A Stop that withdrew what was queued and left nothing working ended what it was sent for, + // named or not. The journal judges it: providers differ on refusing a turn that has ended. + cancelled = true + note = null + } else if (!cancelled && input.turnId !== undefined) { + note = { kind: 'status', text: 'The provider had already finished this turn.' } + } else if (!cancelled && input.prompt) { + note = null + } else if (!cancelled) { + // Sent only while the chat reads working, so a Stop that ended nothing must say why. + const detail = outcome.refusal?.detail + note = { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('stopRefused', detail ? { detail } : {}), + { ...ctx.failureTextContext, surface: 'row' } + ) + } + } + } catch (error) { + if (input.prompt) { + throw error + } + // The adapter's error is Orca's; the row says only that the stop is unconfirmed. + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + } + if (runningCommand && !cancelled) { + await input.stopChild?.() + cancelled = true + note = { kind: 'status', text: 'Cancellation requested.' } + } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } + const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } + if (input.scope || note === null) { + return { ok: true, value } + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await ctx.journal.appendItem( + structuredAgentSessionStopNoteIdentity(input.clientOperationId), + note, + { fence: ctx.fence, turnScope } + ) + return { ok: true, value } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index f10b64be1db..370dd12500d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -113,9 +113,8 @@ export async function performPrompt( committed.item = await ctx.journal.appendItem( identity, { ...prompt, resolution }, - { - fence: ctx.fence - } + // A revision: the prompt keeps the turn it was raised in. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } }) @@ -138,7 +137,7 @@ export async function performPrompt( surface: 'row' }) }, - { fence: ctx.fence } + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } const appended = committed.item diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 576743ebac4..2a9ed84012b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -1,9 +1,10 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -29,7 +30,7 @@ afterEach(async () => { describe('performCancel', () => { it('acknowledges only the request and leaves the running lifecycle row intact', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const lifecycleIdentity = { provider: 'legacy' as const, agent: 'codex' as const, @@ -43,7 +44,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -77,7 +78,7 @@ describe('performCancel', () => { it('hands the adapter a live-turn read of the published journal', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-live-turn-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const lifecycleIdentity = { provider: 'legacy' as const, agent: 'codex' as const, @@ -91,7 +92,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) let resolveLiveTurnId: (() => string | null) | undefined const cancelTurn = vi.fn( @@ -123,14 +124,14 @@ describe('performCancel', () => { text: 'Done.', turnLifecycle: { turnId: 'turn-1', state: 'completed' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(resolveLiveTurnId?.()).toBeNull() }) it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( { provider: 'legacy', @@ -143,7 +144,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { sessionId: 'session-1', @@ -177,7 +178,7 @@ describe('performCancel', () => { it('stops background tasks without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -209,7 +210,7 @@ describe('performCancel', () => { it('routes one background task id without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -244,3 +245,87 @@ describe('performCancel', () => { expect(journal.snapshot().items).toEqual([]) }) }) + +describe('what a conversation Stop reports when the provider stopped nothing', () => { + async function cancelWith( + outcome: Awaited>, + input: { turnId?: string; withdrewQueued?: boolean }, + turnRow: 'none' | 'running' | 'lands-on-flush' = 'none' + ) { + root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-report-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const openTurn = () => + journal.appendItem( + { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + }, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + if (turnRow === 'running') { + await openTurn() + } + const ctx: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => outcome), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + flushStreamedEvents: async () => { + if (turnRow === 'lands-on-flush') { + await openTurn() + } + }, + now: () => 1 + } + const result = await performCancel(ctx, { clientOperationId: 'cancel-report-1', ...input }) + const rows = journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'status' && !item.body.turnLifecycle ? [item.body.text] : [] + ) + return { cancelled: result.ok && result.value.cancelled, rows } + } + + it('reports a Stop naming no turn that withdrew what was queued, with nothing left working, as a success', async () => { + expect(await cancelWith({ cancelled: false }, { withdrewQueued: true })).toEqual({ + cancelled: true, + rows: [] + }) + }) + + it('keeps a Stop naming no turn not cancelled while the journal still reads working', async () => { + const reported = await cancelWith({ cancelled: false }, { withdrewQueued: true }, 'running') + expect(reported.cancelled).toBe(false) + expect(reported.rows).toHaveLength(1) + expect(reported.rows).not.toContain('The provider had already finished this turn.') + }) + + it('reads the journal after its streamed rows land: a turn whose send was accepted first is still working', async () => { + const reported = await cancelWith( + { cancelled: false }, + { turnId: 'turn-0', withdrewQueued: true }, + 'lands-on-flush' + ) + expect(reported).toEqual({ + cancelled: false, + rows: ['The provider had already finished this turn.'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49642b9a897..13aa9d72aea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -13,29 +13,37 @@ import { } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, - AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { refuse, - type AgentSessionCancelResult, type AgentSessionRefusalReason, type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' -import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + handOverStructuredAgentSessionCommand, + structuredAgentSessionHandoverOrigin, + type StructuredAgentSessionCommandHandoverContext +} from './structured-agent-session-command-turn' +import { + classifyJournalOpenFailure, + isJournalWrittenByNewerOrca, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' +export { performCancel } from './structured-agent-session-turns-cancel' export type AgentSessionTurnContext = { sessionId: string @@ -53,6 +61,8 @@ export type AgentSessionTurnContext = { flushStreamedEvents: () => Promise /** What the host holds about the child this dispatch is for, read at the moment it is needed. */ providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a Stop's refusal row names. */ + failureTextContext?: AgentSessionFailureWordsContext now: () => number } @@ -96,14 +106,6 @@ async function dispatchSafely( } } -async function appendStatus( - ctx: AgentSessionTurnContext, - clientMessageId: string, - body: AgentJournalStatusItem -): Promise { - await ctx.journal.appendItem({ provider: 'orca', clientMessageId }, body, { fence: ctx.fence }) -} - /** * One id, one delivery. A submission that already exists replays its recorded * outcome and NEVER goes back on the wire, whatever state it is in and whatever @@ -120,6 +122,8 @@ export async function performSend( clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem + /** Who asked for the turn; absent on callers that predate it. */ + origin?: 'client' | 'host' } ): Promise> { const existing = ctx.journal @@ -139,7 +143,17 @@ export async function performSend( } try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) - } catch { + } catch (error) { + // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So + // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the + // journal already classified (a copy that did not verify). + if ( + isAgentSessionRefusalError(error) || + classifyJournalOpenFailure(error) === 'journalCorrupt' || + isJournalWrittenByNewerOrca(error) + ) { + return { ok: false, refusal: journalOpenRefusal(error) } + } return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } return { @@ -151,13 +165,7 @@ export async function performSend( } } -export type AgentSessionHandoverContext = Pick< - AgentSessionTurnContext, - 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' -> & { - /** Who a start failure met at dispatch names, as the start's own row does. */ - failureTextContext?: AgentSessionFailureWordsContext -} +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -178,10 +186,25 @@ export async function handOverSubmission( }) return } - await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) - // The row written at acceptance is the send's instant on the host clock; the turn this - // dispatch opens records it so the live counter never re-anchors at turn-open. - const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) + if (body.command) { + await handOverStructuredAgentSessionCommand(ctx, submission, body) + return + } + // The message joins the turn running at handover, a steer, or opens its own. + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + // The handover row's instant on the host clock; the turn this dispatch opens records it so the + // live counter never re-anchors at turn-open. + const outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + structuredAgentSessionHandoverOrigin(ctx.journal, submission) + ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return @@ -234,66 +257,3 @@ function requireSubmission( } return submission } - -export async function performCancel( - ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - turnId: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - } -): Promise> { - if (input.prompt) { - const validated = validatePendingPrompt(ctx, input.prompt) - if (!validated.ok) { - return validated - } - } - let cancelled = false - let note: AgentJournalStatusItem = { kind: 'status', text: 'Cancellation requested.' } - try { - const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) - cancelled = input.scope - ? ( - await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) - }) - )?.cancelled === true - : ( - await ctx.adapter.cancelTurn({ - sessionId: ctx.sessionId, - turnId: input.turnId, - fence: ctx.fence, - // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => ctx.journal.activeTurnId(), - ...(dispatchStatus ? { dispatchStatus } : {}), - ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) - }) - ).cancelled - if (!cancelled) { - note = { kind: 'status', text: 'The provider had already finished this turn.' } - } - } catch (error) { - if (input.prompt) { - throw error - } - // The adapter's error is Orca's; the row says only that the stop is unconfirmed. - note = { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) - } - } - if (cancelled && input.prompt) { - await ctx.flushStreamedEvents() - } - if (input.scope) { - return { ok: true, value: { turnId: input.turnId, cancelled } } - } - // Keyed by the operation id so a replayed cancel upserts one item, not two. - await appendStatus(ctx, input.clientOperationId, note) - return { ok: true, value: { turnId: input.turnId, cancelled } } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 451629f7b45..2cb38a46634 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -93,6 +93,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), @@ -165,6 +166,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -223,7 +225,18 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('The agent'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' + }, + // The exit belongs to the turn it ended. + turnScope: { + kind: 'turn', + turnItemId: agentJournalItemKey({ + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-2', + ordinal: 0 + }) } }, { @@ -235,7 +248,8 @@ describe('provider-exit settlement', () => { state: 'interrupted', startedAt: 30, completedAt: 1_234 - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -271,6 +285,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -313,7 +328,8 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('Claude'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' } }) ]) @@ -327,6 +343,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -364,7 +381,8 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('Claude'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' } }) ] @@ -377,6 +395,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 5025c5a6f82..3f758f81607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -25,7 +25,7 @@ type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { export type StructuredAgentSessionUnexpectedExitSession = Pick< StructuredAgentSessionHostSession, 'child' | 'lastEndedChild' -> & { journal: DeadGenerationJournal & Pick } +> & { journal: DeadGenerationJournal & Pick } export type StructuredAgentSessionUnexpectedExitContext< TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession @@ -106,7 +106,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, - failureTextContext: structuredAgentSessionFailureWordsContext(record), + failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 957ac2f275d..3d38c1cb38d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -12,7 +12,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -23,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -80,11 +82,11 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${claude.connections.length + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index a492b0b65ce..3ae6da5fa49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The profiles already shipped into a dead end. // // Every record here is a shape taken from a real wedged store: a lease that no acquisition, no @@ -10,7 +11,7 @@ // leave the lease in a state an attach can claim, and these tests prove that by adjudicating it // rather than by reading fields off it. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' @@ -30,11 +31,13 @@ import type { PersistedAgentSessionRecord, PersistedAgentSessionRuntimeKind } from '../../../shared/agent-session-legacy-handoff-lease' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -46,6 +49,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -110,21 +114,8 @@ function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord { } async function seedStore(record: PersistedAgentSessionRecord): Promise { - const directory = join(root, 'store') - await mkdir(directory, { recursive: true }) - await writeFile( - join(directory, AGENT_SESSION_STORE_FILE_NAME), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [record.sessionId]: record }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }), - 'utf-8' - ) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await seedTestAgentSessionRecordStore(root, { records: [record] }) + store = await openTestAgentSessionRecordStore(root) } /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ @@ -140,7 +131,7 @@ function openHost(overrides: Partial = {}): void setOption: vi.fn(), supportsCreate: () => true } as unknown as StructuredAgentSessionAdapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', now: () => NOW, @@ -206,14 +197,14 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< ? { kind: 'codex', threadId: THREAD } : { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } }, - journalDir: journalDirectoryFor(root, { workspaceId: LOCATION.workspaceId, sessionId: SESSION }) + database: openTestJournalHostDatabase(root) }) await journal.appendItem( provider === 'codex' ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: SEEDED_TURN_STARTED_AT }, - { fence: 13 } + { fence: 13, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() } @@ -282,10 +273,7 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).not.toHaveBeenCalled() await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(root) openHost() await host.restoreReadableSessions() @@ -582,7 +570,7 @@ describe('already-wedged profiles become usable on load', () => { await restoredJournal().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, - { fence } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // A reconnecting client replays its attach; the same operation admits the live owner. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 585cbd4fbbd..24ae62a872b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -9,13 +9,17 @@ import type { import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow +} from '../agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from './agent-session-history-page' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -36,10 +40,13 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: root + stateDirectory: root }) for (let ordinal = 1; ordinal <= 20; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } }) @@ -167,15 +174,15 @@ async function reopenWithOversizedRemoval(afterSequence: number): Promise [item.itemId, item.body])) + const bodyOf = (itemId: string) => bodies.get(itemId) + // A conversation command in flight is nothing to resume: the user ran it, not the agent. + const newest = newestStructuredAgentSessionTurn(items) + const inFlightSubmission = pendingSubmissionInFlight(submissions) + if ( + (newest?.state === 'running' && isStructuredAgentSessionCommandTurn(newest, bodyOf)) || + (inFlightSubmission && + isStructuredAgentSessionCommandEntry( + bodyOf(agentJournalSubmissionKey(inFlightSubmission.clientMessageId)) + )) + ) { + return null + } const inFlight = structuredAgentSessionWorkInFlight(items, submissions) if (inFlight) { return inFlight } - const newest = newestStructuredAgentSessionTurn(items) - return newest ? { kind: 'turn', id: newest.turnId } : null + // A settled lead whose children were the work anchors on its last real turn. + const lastRequest = items.findLast((item) => { + const turn = readAgentJournalTurn(item.body) + return turn !== null && !isStructuredAgentSessionCommandTurn(turn, bodyOf) + }) + const turn = readAgentJournalTurn(lastRequest?.body) + return turn ? { kind: 'turn', id: turn.turnId } : null } function boundedLabel(text: string | undefined): string { diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts deleted file mode 100644 index 4c565bcf4e2..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { settleInterruptedCompaction } from './structured-compaction-recovery' - -describe('settleInterruptedCompaction', () => { - it("records the unconfirmed compaction in its fact's own words, on the row and the command", async () => { - const command = { - command: 'compact' as const, - state: 'unknown' as const, - phase: 'prepared' as const, - operationId: 'op-1', - callerKey: 'client-1', - runtimeFence: 1 - } - const setConversationCommand = vi.fn(async () => undefined) - const store = { - getRecord: () => ({ conversationCommand: command }), - setConversationCommand, - recordOperationOutcome: vi.fn(async () => undefined) - } - const appendItem = vi.fn(async () => ({ cursor: { epoch: 'e', sequence: 1 } })) - - await settleInterruptedCompaction( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the members stubbed above. - store as unknown as AgentSessionRecordStore, - 'session-1', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function calls only `appendItem`. - { appendItem } as unknown as AgentSessionJournal, - 2 - ) - - const words = { - text: 'Compaction completion is unconfirmed.', - failure: { kind: 'compactionUnconfirmed' } - } - expect(appendItem).toHaveBeenCalledWith( - { provider: 'orca', clientMessageId: 'compact:op-1' }, - { kind: 'status', ...words }, - { fence: 2 } - ) - expect(setConversationCommand).toHaveBeenCalledWith( - 'session-1', - 2, - expect.objectContaining({ error: words.text, failure: words.failure, state: 'unknown' }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts deleted file mode 100644 index b581d746f62..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' - -/** A newly acquired owner cannot still be executing the previous owner's command. */ -export async function recoverInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if (command?.runtimeFence === undefined || command.runtimeFence === fence) { - return - } - await settleInterruptedCompaction(store, sessionId, journal, fence) -} - -/** A compaction still prepared whose child can no longer finish it: its outcome is unknown. */ -export async function settleInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if (command?.command !== 'compact' || command.phase !== 'prepared') { - return - } - const words = agentSessionFailureWords(agentSessionFailureFact('compactionUnconfirmed'), { - surface: 'row' - }) - await journal.appendItem( - { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', ...words }, - { fence } - ) - const recovered = { - ...command, - phase: 'committed' as const, - state: 'unknown' as const, - error: words.text, - failure: words.failure - } - await store.setConversationCommand(sessionId, fence, recovered) - await store.recordOperationOutcome({ - callerKey: command.callerKey, - operationId: command.operationId, - outcome: { status: 'succeeded', sessionId, conversationCommand: recovered } - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index bf5c72fc938..5e1b06abb9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -123,3 +123,72 @@ describe('conversationCommandBlocked background work', () => { ) }) }) + +describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => { + const staleTurn = { + items: [{ itemId: 'turn-1', body: { kind: 'turn', turnId: 'turn-1', state: 'running' } }] + } + + it("does not refuse over a dead generation's running turn, which the start sweeps", () => { + const ctx = contextWith(undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. + ctx.journal.snapshot = () => staleTurn as never + expect(conversationCommandBlocked(ctx, RECORD, [], 'at-rest')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD, [])).toMatchObject({ + details: { reason: 'turnActive' }, + message: 'Wait for the current turn to finish before using this command.' + }) + }) + + it("ignores an older build's compaction record", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { command: 'compact', phase: 'prepared', state: 'unknown' } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() + }) + + // A clear's commit is its only durable write, so a record short of it never changed the chat. + it("ignores a clear that never committed, as an older build's record leaves one", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { + command: 'clear', + phase: 'prepared', + state: 'unknown', + replacementSessionId: 'clear-replacement' + } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() + }) + + it('refuses on a committed clear', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { + command: 'clear', + phase: 'committed', + state: 'completed', + replacementSessionId: 'clear-replacement' + } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + }) + }) + + it('at handover, lets the command itself and messages queued behind it wait', () => { + const ctx = contextWith(undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. + const queued = [ + { clientMessageId: 'command', dispatchState: 'pending', handoverRecorded: true }, + { clientMessageId: 'behind', dispatchState: 'pending', handoverRecorded: true } + ] as never + ctx.journal.submissions = () => queued + expect(conversationCommandBlocked(ctx, RECORD, [], 'handover')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index e1f33fa9b80..65a213c0168 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,5 +1,6 @@ import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-row-model' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' @@ -32,12 +33,24 @@ function blocked( return refuse('agent_session_operation_invalid', { reason }, message) } -/** `childWork` is the session's child records as the chat strip reads them: a refusal may only - * cite work the strip lists, and ask for a stop only when the strip offers one. */ +export function conversationCommandInFlight(): AgentSessionWireRefusal { + return blocked('conversationCommandInFlight', 'Wait for the conversation operation to finish.') +} + +/** + * Why a conversation command may not run now; null when it may. + * + * `childWork` is the session's child records as the chat strip reads them: a refusal may only + * cite work the strip lists, and ask for a stop only when the strip offers one. + * `at-rest`: a command accepted with no child running. A running turn on record then belongs to a + * dead generation, which the start before handover sweeps, so it refuses nothing yet. + * `handover`: the command is the oldest queued message, and those queued behind it wait for it. + */ export function conversationCommandBlocked( ctx: ConversationCommandAdmissionContext, record: AgentSessionRecord, - childWork: readonly AgentChildWorkView[] | undefined + childWork: readonly AgentChildWorkView[] | undefined, + admission?: 'at-rest' | 'handover' ): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { @@ -53,19 +66,10 @@ export function conversationCommandBlocked( 'This conversation has been cleared. Open the current conversation to continue.' ) } - if ( - record.conversationCommand?.state === 'unknown' && - record.conversationCommand.phase === 'prepared' - ) { - return blocked( - 'conversationCommandUnconfirmed', - 'The previous conversation operation is unconfirmed.' - ) - } if (record.lease.handoffStage || record.lease.handoffOperationId) { return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } - if (activeStructuredAgentSessionTurnId(items)) { + if (admission !== 'at-rest' && activeStructuredAgentSessionTurnId(items)) { return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( @@ -92,7 +96,8 @@ export function conversationCommandBlocked( if ( ctx.journal.submissions().some( (entry) => - entry.dispatchState === 'pending' || + (entry.dispatchState === 'pending' && + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry))) || // Doubt left by an earlier child is not this one's work in flight. (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts index 0d4b630a4f5..fffe78c9663 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts @@ -2,46 +2,30 @@ // both read the host's child records through the one sink read. The provider tracker's own roster // is present and claims live work throughout; nothing here may be decided by it. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' import { - HOST_TEST_NOW, - HOST_TEST_SESSION, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' + attach, + CALLER, + envelope, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { HOST_TEST_NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data' -const caller = { callerKey: 'desktop' } -let directory: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -const compact = vi.fn>() +let state: ReturnType +let compact: Mock> /** What the store holds for the session; the sink serves it to every reader. */ let records: AgentChildWorkView[] = [] function compactParams() { return { command: 'compact' as const, - envelope: { - sessionId: HOST_TEST_SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: HOST_TEST_SESSION, - fields: { command: 'compact' } - }) - } + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) } } @@ -61,75 +45,54 @@ function devServer(overrides: Partial = {}): AgentChildWorkV } } -beforeEach(async () => { +/** The provider ends the command's turn, as the child's journal translator does. */ +function finish(): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +async function commandTurnState(clientMessageId: string): Promise { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + const item = (await state.host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (entry) => entry.itemId === itemId + ) + return item?.body.kind === 'turn' ? item.body.state : undefined +} + +beforeEach(() => { records = [] - resetHostTestOperationIds() - compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) - directory = await mkdtemp(join(tmpdir(), 'orca-command-child-work-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) - const statusSink: StructuredAgentSessionStatusSink = { - publish: () => {}, - forget: () => {}, - publishChildWork: () => {}, - readChildWork: () => records - } + state = hostTestState() + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) const trackerRoster: AgentSessionBackgroundTaskState = { state: 'monitoring', tasks: [{ id: 'tracker-only', kind: 'command', description: 'not on the strip' }] } - const adapter = { - supportsLocation: () => true, - acquire: vi.fn(async (input: Parameters[0]) => ({ - process: { - hostId: 'local', - pid: 4001, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: 'link-1', - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: 'created' as const, - handle: { provider: 'codex' as const, threadId: '00000000-0000-4000-8000-000000000001' } - } - })), - dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: async () => {}, - setOption: async () => {}, + Object.assign(state.host.deps.adapter, { compact, - releaseAcquisition: async () => true, - closeSession: async () => true, - readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }), backgroundTaskStops: () => ({ supportsTaskStop: true, supportsStopAll: true }), // A tracker that drifted from the records: it still claims work the strip does not list. backgroundTaskState: () => trackerRoster - } - host = new StructuredAgentSessionHost({ - store, - adapter, - journalRoot: directory, - claimKeyId: 'key', - now: () => HOST_TEST_NOW, - mintSpawnToken: () => 'spawn-1', - statusSink }) - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(directory, { recursive: true, force: true }) + serveHostTestChildWork(() => records) }) describe('conversation command admission reads the strip’s child records', () => { it('refuses only while the strip lists live work, and names the stop the strip offers', async () => { + await attach() const strip: (AgentSessionBackgroundTaskState | null)[] = [] - host.subscribe({ + await state.host.subscribe({ id: 'strip', sessionId: HOST_TEST_SESSION, emit: (event) => { @@ -148,26 +111,71 @@ describe('conversation command admission reads the strip’s child records', () // Nothing on the strip: the drifted tracker's roster refuses nothing. expect(stripRows()).toEqual([]) - expect(await host.conversationCommand(caller, compactParams())).toMatchObject({ ok: true }) - expect(compact).toHaveBeenCalledTimes(1) + const first = compactParams() + expect(await state.host.conversationCommand(CALLER, first)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) + finish() + await vi.waitFor(async () => + expect(await commandTurnState(first.envelope.clientOperationId)).toBe('completed') + ) records = [devServer()] - host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) expect(stripRows()).toEqual([ { description: 'npm run dev', membership: 'live', stoppable: true, providerId: 'task-dev' } ]) - expect(await host.conversationCommand(caller, compactParams())).toMatchObject({ + expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: false, refusal: { message: 'Stop background tasks before using this command.' } }) records = [devServer({ state: 'done', membership: 'settled', outcome: 'succeeded' })] - host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) // Still listed, as finished: a finished row blocks nothing. expect(stripRows()).toEqual([ { description: 'npm run dev', membership: 'settled', stoppable: true, providerId: 'task-dev' } ]) - expect(await host.conversationCommand(caller, compactParams())).toMatchObject({ ok: true }) - expect(compact).toHaveBeenCalledTimes(2) + expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(2)) + }) + + it('refuses a queued command at handover once the strip lists live work', async () => { + const acquireChild = state.acquire.getMockImplementation()! + // Each child names its generation, so the exit below ends exactly the one it names. + state.acquire.mockImplementation(async (input) => ({ + ...(await acquireChild(input)), + acquisitionGeneration: `generation-${input.fence}` + })) + await attach() + // The provider exits, so the next command is accepted at rest and waits for a new start. + const fence = state.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(HOST_TEST_SESSION)?.child).toBeNull()) + // The records gain live work while that start runs, after the command was accepted. + const start = state.acquire.getMockImplementation()! + state.acquire.mockImplementationOnce(async (input) => { + records = [devServer()] + return start(input) + }) + const params = compactParams() + expect(await state.host.conversationCommand(CALLER, params)).toMatchObject({ ok: true }) + + await vi.waitFor(async () => { + const submission = (await state.host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + expect(submission?.dispatchState).toBe('rejected') + }) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(compact).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 662ec70e8dd..4e4e42c46e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -1,46 +1,41 @@ -import { refuse } from '../../../shared/agent-session-wire-refusals' +import { conversationCommandInFlight } from './structured-conversation-command-admission' import { sendStructuredAgentSessionTurn } from './structured-agent-session-host-mutations' import { runStructuredConversationCommand, type ConversationCommandParams } from './structured-conversation-command' +import { runStructuredCompaction } from './structured-conversation-compaction' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' export class StructuredConversationCommandController { + /** Held only by a clear, which replaces the conversation a send would land in. A compaction is + * a queued message, and sends accepted behind it wait for it in the queue. */ readonly pending = new Map() constructor( private readonly context: () => StructuredAgentSessionMutationContext, - private readonly host: Pick + private readonly host: Pick< + StructuredAgentSessionHost, + 'attach' | 'flushStreamedEvents' | 'waitForSendSettlement' + > ) {} send = ( caller: StructuredAgentSessionCaller, params: Parameters[2] ): ReturnType => this.pending.has(params.envelope.sessionId) - ? Promise.resolve({ - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCommandInFlight' }, - 'Wait for the conversation operation to finish.' - ) - }) + ? Promise.resolve({ ok: false, refusal: conversationCommandInFlight() }) : sendStructuredAgentSessionTurn(this.context(), caller, params) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { + if (params.command === 'compact') { + return runStructuredCompaction(this.context(), this.host, caller, params) + } const key = JSON.stringify([caller.callerKey, params.envelope.clientOperationId]) const pending = this.pending.get(params.envelope.sessionId) if (pending && pending.key !== key) { - return Promise.resolve({ - ok: false as const, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCommandInFlight' }, - 'Wait for the conversation operation to finish.' - ) - }) + return Promise.resolve({ ok: false as const, refusal: conversationCommandInFlight() }) } const entry = pending ?? { key, count: 0 } entry.count++ @@ -50,6 +45,9 @@ export class StructuredConversationCommandController { if (--entry.count === 0 && this.pending.get(params.envelope.sessionId) === entry) { this.pending.delete(params.envelope.sessionId) } + // A clear can settle with no journal commit (a failed attach), and drafts held behind + // its prepared phase would otherwise wait for an unrelated commit. + this.context().wakeQueuedDrain?.(params.envelope.sessionId) } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts new file mode 100644 index 00000000000..a2c0e9aef6b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { structuredCompactionOutcome } from './structured-conversation-command-outcome' + +describe('structuredCompactionOutcome', () => { + it('is a success only when the provider reported the compaction', () => { + expect(structuredCompactionOutcome({ compacted: true, interruptRequested: true })).toEqual({ + outcome: 'success' + }) + }) + + it("reads no compaction after Orca's interrupt as the user's cancellation", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: true, + failed: { detail: { text: 'API Error: Request was aborted.', audience: 'person' } } + }) + ).toEqual({ outcome: 'cancellation' }) + }) + + it("reads a failure the provider reported as a failed compaction, keeping the provider's words", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: false, + failed: { detail: { text: 'Not enough messages to compact.', audience: 'person' } } + }) + ).toEqual({ + outcome: 'failure', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } + }) + expect( + structuredCompactionOutcome({ compacted: false, interruptRequested: false, failed: {} }) + ).toEqual({ outcome: 'failure', failure: { kind: 'compactionFailed' } }) + }) + + it('reads a compaction the provider never reported as a failure it did not confirm', () => { + expect(structuredCompactionOutcome({ compacted: false, interruptRequested: false })).toEqual({ + outcome: 'failure', + failure: { kind: 'compactionUnconfirmed' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts new file mode 100644 index 00000000000..cc9513a6ad1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts @@ -0,0 +1,44 @@ +// How a conversation command the provider ran ended, read the same way for every provider. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' + +export type StructuredConversationCommandOutcome = { + outcome: 'success' | 'failure' | 'cancellation' + /** On a failure: what its row reports. */ + failure?: AgentSessionFailureFact +} + +/** What the provider showed of one compaction while it ran. */ +export type StructuredCompactionEvidence = { + /** The provider reported the conversation compacted: Claude's boundary, Codex's item. */ + compacted: boolean + /** Orca asked the provider to stop the command. */ + interruptRequested: boolean + /** The provider said the compaction failed, with its words for a person when it gave any. */ + failed?: { detail?: ProviderDiagnostic } | null +} + +/** Only a compaction the provider reported doing is a success: Claude answers a stopped `/compact` + * with the same success result as a finished one, so the result's own verdict cannot decide. With + * none, one Orca asked to stop is the user's cancellation; anything else failed — reported as the + * provider's failure when it said so, and otherwise as a compaction it never confirmed. */ +export function structuredCompactionOutcome( + evidence: StructuredCompactionEvidence +): StructuredConversationCommandOutcome { + if (evidence.compacted) { + return { outcome: 'success' } + } + if (evidence.interruptRequested) { + return { outcome: 'cancellation' } + } + return { + outcome: 'failure', + failure: evidence.failed + ? agentSessionFailureFact('compactionFailed', { detail: evidence.failed.detail }) + : agentSessionFailureFact('compactionUnconfirmed') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 445326f60b0..89b07045b3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -1,16 +1,19 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { cp, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -19,40 +22,81 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } let directory: string +let generation: number +let clock: number let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +let hosts: StructuredAgentSessionHost[] let adapter: StructuredAgentSessionAdapter const compact = vi.fn>() let acquisitions = 0 -function commandParams(command: AgentSessionConversationCommand) { +function envelope(method: string, fields: Record) { return { - command, - envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, sessionId: HOST_TEST_SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: HOST_TEST_SESSION, - fields: { command } - }) - } + fields + }) } } +function commandParams(command: AgentSessionConversationCommand) { + return { command, envelope: envelope('agentSession.conversationCommand', { command }) } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { body, envelope: envelope('agentSession.send', { body }) } +} + +const generationRoot = () => join(directory, `generation-${generation}`) + +let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } + +async function openHost(): Promise { + store = await openTestAgentSessionRecordStore(generationRoot()) + host = new StructuredAgentSessionHost({ + store, + adapter, + journalDatabase: openTestJournalHostDatabase(generationRoot()), + claimKeyId: 'key', + now: () => clock, + mintSpawnToken: () => `spawn-${acquisitions}`, + // The owners a restarted host finds died with the process that started them, unless a test says otherwise. + probeOwner: async () => ownerProbe + }) + hosts.push(host) +} + +/** A crash and relaunch: the next host opens what the dying one had written, and nothing after. */ +async function restartHost(): Promise { + await store.renewLeases([]) + const dying = generationRoot() + generation++ + await cp(dying, generationRoot(), { + recursive: true, + filter: (source) => !source.endsWith('.tmp') && !source.includes('.lock') + }) + await openHost() +} + beforeEach(async () => { resetHostTestOperationIds() + ownerProbe = { outcome: 'pid-absent' } acquisitions = 0 - compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) + generation = 0 + clock = HOST_TEST_NOW + hosts = [] + compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) adapter = { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, @@ -85,18 +129,11 @@ beforeEach(async () => { answerPrompt: async () => {}, setOption: async () => {}, compact, - releaseAcquisition: async () => true, - closeSession: async () => true, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }) } - host = new StructuredAgentSessionHost({ - store, - adapter, - journalRoot: directory, - claimKeyId: 'key', - now: () => HOST_TEST_NOW, - mintSpawnToken: () => `spawn-${acquisitions}` - }) + await openHost() expect( await host.attach(caller, hostTestAttachParams(null, { options: { effort: 'low' } })) ).toMatchObject({ ok: true }) @@ -104,89 +141,13 @@ beforeEach(async () => { }) afterEach(async () => { - await host.flushAllStreamedEvents() + for (const each of hosts) { + await each.flushAllStreamedEvents() + } await rm(directory, { recursive: true, force: true }) }) describe('host conversation commands', () => { - it('compacts once without an ordinary message submission and replays its receipt', async () => { - const params = commandParams('compact') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true - }) - expect(compact).toHaveBeenCalledTimes(1) - expect(adapter.dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(history.page.submissions).toEqual([]) - expect( - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running' - ) - ).toBe(false) - }) - - it('reports provider compaction failure without a stuck lifecycle', async () => { - const detail = { text: 'Not enough messages to compact.', audience: 'person' as const } - compact.mockResolvedValue({ outcome: 'failed', detail }) - const failure = { kind: 'compactionFailed', detail } - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { - state: 'completed', - error: 'Compaction failed: Not enough messages to compact.', - failure - } - }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.state).toBe('completed') - const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ - kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', - failure - }) - }) - - it('says only that compaction failed when the provider gave no words', async () => { - compact.mockResolvedValue({ outcome: 'failed' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } - }) - }) - - it('records a compaction the provider never confirmed as unconfirmed, not failed', async () => { - compact.mockResolvedValue({ outcome: 'unconfirmed' }) - const failure = { kind: 'compactionUnconfirmed' } - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { error: 'Compaction completion is unconfirmed.', failure } - }) - const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ - kind: 'status', - text: 'Compaction completion is unconfirmed.', - failure - }) - }) - - it('keeps an unknown compaction from being executed again', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow('connection lost') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - - /** The replacement seeds from what the provider reports now, not from what the - * retired record happened to store — the same rule acquire and handoff apply. */ it('adopts the reported Fast preference into the replacement record', async () => { adapter.readOptions = async () => ({ models: [], @@ -319,41 +280,8 @@ describe('host conversation commands', () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) - expect(compact).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) }) - it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: StructuredSessionCompactionResult) => void - compact.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const params = commandParams('compact') - const running = host.conversationCommand(caller, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalled()) - expect( - await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - ).toMatchObject({ ok: false }) - const turnId = `compact:${params.envelope.clientOperationId}` - const cancel = await host.cancel(caller, { - turnId, - envelope: { - ...params.envelope, - clientOperationId: hostTestOperationId(), - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: HOST_TEST_SESSION, - fields: { turnId } - }) - } - }) - expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(adapter.cancelTurn).toHaveBeenCalled() - finish({ outcome: 'compacted' }) - await running - }) - it('reconstructs a committed replacement after the ledger settlement is lost', async () => { const persist = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { @@ -372,19 +300,6 @@ describe('host conversation commands', () => { expect(acquisitions).toBe(2) }) - it('repairs an unknown receipt when the provider completes late', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await compact.mock.calls[0]![0].onLateResult?.({ outcome: 'compacted' }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'completed' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => { const result = await host.conversationCommand(caller, commandParams('clear')) if (!result.ok) { @@ -397,26 +312,383 @@ describe('host conversation commands', () => { await host.setSessionTabVisibility(result.value.replacementSessionId!, false) expect(host.conversationReplacements()).toEqual([]) }) - it('keeps the old compact outcome unknown but restores usability after verified reacquisition', async () => { - compact.mockRejectedValue(new Error('lost response')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await host.close(HOST_TEST_SESSION) - const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' +}) + +describe('a clear that never committed', () => { + /** The replacement's start answers with a refusal that proves nothing either way. */ + function refuseReplacementStartOnce() { + vi.spyOn(host, 'attach').mockResolvedValueOnce({ + ok: false, + refusal: { code: 'agent_session_operation_capacity', message: 'Too many operations.' } }) + } + + async function clearCommits(params = commandParams('clear')) { + const result = await host.conversationCommand(caller, params) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + return result.ok ? result.value.replacementSessionId! : '' + } + + it('refuses nothing afterwards: a rewind, a compaction and a send all run', async () => { + adapter.rewindSupport = () => ({ supported: true }) + refuseReplacementStartOnce() + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( + 'Too many operations.' + ) + // Past every conversation check: only the stale epoch it names stops it. + expect( + await host.rewind(caller, { + envelope: envelope('agentSession.rewind', { + itemId: 'item-1', + expectedEpoch: 'stale-epoch' + }), + itemId: 'item-1', + expectedEpoch: 'stale-epoch' + }) + ).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) + expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + ok: true + }) + expect(await host.send(caller, sendParams('still here'))).toMatchObject({ ok: true }) + }) + + it('lets a clear under a new operation id commit', async () => { + refuseReplacementStartOnce() + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() + const replacement = await clearCommits() + expect(store.listVisibleSessionIds()).toEqual([replacement]) + }) + + // Its id is too old to start anything now, and it started nothing to finish. + it('lets a clear under a new operation id commit a day after a try that started nothing', async () => { + refuseReplacementStartOnce() + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() + clock += AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 60_000 + const params = commandParams('clear') + params.envelope.clientOperationId = `${clock}-${'a'.repeat(32)}` + await clearCommits(params) + }) + + it('reruns under the same operation id and starts exactly one replacement', async () => { + refuseReplacementStartOnce() + const params = commandParams('clear') + await expect(host.conversationCommand(caller, params)).rejects.toThrow() + expect(acquisitions).toBe(1) + await clearCommits(params) + expect(acquisitions).toBe(2) + }) + + /** What an older build left when its clear's outcome was lost: gated every write until now. */ + async function restartOverAnOlderBuildsUnconfirmedClear() { + const record = store.getRecord(HOST_TEST_SESSION)! + await store.setConversationCommand(HOST_TEST_SESSION, record.lease.runtimeFence, { + command: 'clear', + runtimeFence: record.lease.runtimeFence, + operationId: hostTestOperationId(), + callerKey: caller.callerKey, + phase: 'prepared', + state: 'unknown', + replacementSessionId: 'clear-from-an-older-build' + }) + await restartHost() + } + + it("accepts a send on a restarted host holding an older build's unconfirmed clear", async () => { + await restartOverAnOlderBuildsUnconfirmedClear() + expect(await host.send(caller, sendParams('after the restart'))).toMatchObject({ ok: true }) + }) + + it("clears on a restarted host holding an older build's unconfirmed clear", async () => { + await restartOverAnOlderBuildsUnconfirmedClear() + await clearCommits() + }) + + /** A clear whose replacement started but whose commit never landed. */ + async function clearThatDiesBeforeItsCommit(params = commandParams('clear')): Promise { + const commit = store.setConversationCommand.bind(store) + let crashed = false + vi.spyOn(store, 'setConversationCommand').mockImplementation(async (...args) => { + if (!crashed && args[2].phase === 'committed' && args[2].replacementSessionId) { + crashed = true + throw new Error('crash before the commit') + } + return commit(...args) + }) + await expect(host.conversationCommand(caller, params)).rejects.toThrow( + 'crash before the commit' + ) + const [orphan] = store + .listRecords() + .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) + expect(host.collaboratorsForTests().sessions.get(orphan!)?.child).toBeTruthy() + return orphan! + } + + function otherRecordIds(): string[] { + return store + .listRecords() + .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) + } + + function startsFor(sessionId: string): number { + return vi + .mocked(adapter.acquire) + .mock.calls.filter(([input]) => input.identity.sessionId === sessionId).length + } + + function sendTo(sessionId: string, text: string) { + const body = hostTestMessage(text) + return host.send(caller, { + body, + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + }) + } + + /** The chat reads cleared onto `replacement`, which starts its agent for its first message. */ + async function expectClearedOnto(replacement: string): Promise { + expect(store.listVisibleSessionIds()).toEqual([replacement]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) + expect(await host.send(caller, sendParams('into the old chat'))).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCleared' } } + }) + const started = startsFor(replacement) + expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(startsFor(replacement)).toBe(started + 1)) + } + + // A restart stopped the replacement the first try started, which leaves it at rest like any + // quiet chat, so the retry switches to it. + it('retried under the same operation id after a crash, finishes onto the replacement it started', async () => { + const params = commandParams('clear') + const orphan = await clearThatDiesBeforeItsCommit(params) + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + const result = await host.conversationCommand(caller, params) + expect(result).toMatchObject({ + ok: true, + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(otherRecordIds()).toEqual([orphan]) + expect(startsFor(orphan)).toBe(1) + await expectClearedOnto(orphan) + }) + + it('retried in the same app session once the idle sweep stopped the replacement, finishes onto it', async () => { + const params = commandParams('clear') + const attach = host.attach.bind(host) + // The replacement starts, but the answer the clear gets proves nothing either way. + vi.spyOn(host, 'attach').mockImplementationOnce(async (...args) => { + await attach(...args) + return { + ok: false, + refusal: { code: 'agent_session_operation_capacity', message: 'Too many operations.' } + } + }) + await expect(host.conversationCommand(caller, params)).rejects.toThrow('Too many operations.') + const [orphan] = otherRecordIds() + expect(store.getRecord(orphan!)?.lease.claimStatus).not.toBe('released') + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(store.getRecord(orphan!)?.lease).toMatchObject({ claimStatus: 'released' }) + const result = await host.conversationCommand(caller, params) + expect(result).toMatchObject({ + ok: true, + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(startsFor(orphan!)).toBe(1) + await expectClearedOnto(orphan!) + }) + + it('retried after the replacement definitely failed to start, still reads that failure', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + vi.spyOn(store, 'setConversationCommand').mockRejectedValueOnce( + new Error('crash before the commit') + ) + const params = commandParams('clear') + await expect(host.conversationCommand(caller, params)).rejects.toThrow( + 'crash before the commit' + ) + const [replacement] = otherRecordIds() + expect(store.getRecord(replacement!)?.lease).toMatchObject({ claimStatus: 'released' }) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, - replayed: true, - value: { state: 'unknown' } + value: { + state: 'completed', + replacementSessionId: undefined, + error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', + failure: { kind: 'notSignedIn' } + } }) - compact.mockResolvedValue({ outcome: 'compacted' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + expect(startsFor(replacement!)).toBe(1) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + }) + + // The client mints a fresh operation id per press; the host still finds that press's earlier try. + it('retried under a fresh operation id while its replacement runs, finishes onto it and starts no other', async () => { + const orphan = await clearThatDiesBeforeItsCommit() + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ ok: true, - value: { state: 'completed' } + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } }) + expect(result.ok && result.value.error).toBeFalsy() + expect(otherRecordIds()).toEqual([orphan]) + expect(startsFor(orphan)).toBe(1) + expect(store.listVisibleSessionIds()).toEqual([orphan]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([orphan]) + expect(await sendTo(orphan, 'first message')).toMatchObject({ ok: true }) + }) + + it('retried under a fresh operation id after a restart, finishes onto the replacement it started', async () => { + const orphan = await clearThatDiesBeforeItsCommit() + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } + }) + expect(otherRecordIds()).toEqual([orphan]) + expect(startsFor(orphan)).toBe(1) + expect(store.getRecord(orphan)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null + }) + await expectClearedOnto(orphan) + }) + + it("refuses another window's /clear while this one's replacement runs, and runs it once that stops", async () => { + const otherWindow = { callerKey: 'mobile' } + const orphan = await clearThatDiesBeforeItsCommit() + expect(await host.conversationCommand(otherWindow, commandParams('clear'))).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + expect(otherRecordIds()).toEqual([orphan]) + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + const result = await host.conversationCommand(otherWindow, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + const replacement = result.ok ? result.value.replacementSessionId! : '' + expect(replacement).not.toBe(orphan) + // Nothing points at the first window's replacement, so nothing lists, opens or starts it. + expect(store.listVisibleSessionIds()).toEqual([replacement]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) + expect(host.collaboratorsForTests().sessions.has(orphan)).toBe(false) + expect(store.getRecord(orphan)?.lease).toMatchObject({ claimStatus: 'released' }) + expect(startsFor(orphan)).toBe(1) + }) + + function failNextReplacementStart() { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + } + + // The other window's failure is the conversation's latest; this window's own still ended its try. + it('starts a new replacement for a /clear after one that committed its failure, in either window', async () => { + for (const each of [caller, { callerKey: 'mobile' }]) { + failNextReplacementStart() + expect(await host.conversationCommand(each, commandParams('clear'))).toMatchObject({ + ok: true, + value: { replacementSessionId: undefined, failure: { kind: 'notSignedIn' } } + }) + } + const failed = otherRecordIds() + expect(failed).toHaveLength(2) + const replacement = await clearCommits() + expect(failed).not.toContain(replacement) + expect(startsFor(replacement)).toBe(1) + expect(store.listVisibleSessionIds()).toEqual([replacement]) + }) + + it('starts a new replacement after a committed failure whose ledger settlement was lost', async () => { + failNextReplacementStart() + const persist = store.recordOperationOutcome.bind(store) + vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { + if (input.outcome.status === 'succeeded' && input.outcome.conversationCommand) { + throw new Error('crash') + } + return persist(input) + }) + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow('crash') + vi.mocked(store.recordOperationOutcome).mockRestore() + const [failed] = otherRecordIds() + const replacement = await clearCommits() + expect(replacement).not.toBe(failed) + expect(store.listVisibleSessionIds()).toEqual([replacement]) + }) + + it("does not gate another window's /clear on a replacement whose stop is only unproven", async () => { + const orphan = await clearThatDiesBeforeItsCommit() + ownerProbe = { outcome: 'indeterminate', reason: 'test' } + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + expect(store.getRecord(orphan)?.lease.claimStatus).not.toBe('released') + const result = await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + expect(result.ok && result.value.replacementSessionId).not.toBe(orphan) + }) + + it('starts afresh when a retry under a new operation id follows a start that definitely failed', async () => { + failNextReplacementStart() + vi.spyOn(store, 'setConversationCommand').mockRejectedValueOnce( + new Error('crash before the commit') + ) + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( + 'crash before the commit' + ) + const [failed] = otherRecordIds() + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(result.ok && result.value.replacementSessionId).not.toBe(failed) + }) + + it('starts afresh when a retry after a restart follows a try whose start never answered', async () => { + let hostDies!: (error: Error) => void + vi.mocked(adapter.acquire).mockImplementationOnce( + () => new Promise((_, reject) => (hostDies = reject)) + ) + const dying = host.conversationCommand(caller, commandParams('clear')).catch(() => {}) + await vi.waitFor(() => expect(otherRecordIds()).toHaveLength(1)) + const [interrupted] = otherRecordIds() + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(result.ok && result.value.replacementSessionId).not.toBe(interrupted) + hostDies(new Error('the host that started it is gone')) + await dying }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index ee44ce369bf..bbc7aea52fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -9,6 +9,10 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import { + agentSessionLeaseAdmitsWriter, + agentSessionLeaseIsReleased +} from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { attachFingerprintFields, @@ -22,40 +26,21 @@ import { } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { conversationCommandBlocked } from './structured-conversation-command-admission' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { - agentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../shared/agent-session-failure' + conversationCommandInFlight, + conversationCommandBlocked +} from './structured-conversation-command-admission' +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' - -/** A compaction that did not succeed, keeping only what the provider wrote for a person. */ -function compactionFailure( - result: StructuredSessionCompactionResult -): AgentSessionFailureFact | undefined { - switch (result.outcome) { - case 'compacted': - return undefined - case 'unconfirmed': - return agentSessionFailureFact('compactionUnconfirmed') - case 'failed': - return agentSessionFailureFact('compactionFailed', { detail: result.detail }) - } -} - -function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { - return failure - ? { kind: 'status' as const, ...agentSessionFailureWords(failure, { surface: 'row' }) } - : { kind: 'status' as const, text: 'Conversation compacted.' } -} +import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' /** A command's `error` is the sentence its row shows. */ -function conversationCommandFailure( +export function conversationCommandFailure( failure: AgentSessionFailureFact | undefined, context: AgentSessionFailureWordsContext = {} ) { @@ -77,6 +62,76 @@ export type ConversationReplacement = { agent: 'claude' | 'codex' } +/** The replacement a /clear try names, and the operation id of its start. */ +function clearReplacementIds(sessionId: string, callerKey: string, operationId: string) { + const digest = createHash('sha256') + .update(JSON.stringify([sessionId, callerKey, operationId])) + .digest('hex') + return { + sessionId: `clear-${digest.slice(0, 40)}`, + attachOperationId: `${parseAgentSessionOperationTimestamp(operationId)}-${digest.slice(0, 32)}` + } +} + +/** + * The try this caller's /clear finishes: the oldest since its last commit whose replacement start + * reached the ledger, else this one. Read from the ledger in admission order, so a retry under a + * fresh operation id finds the replacement an earlier try started. + */ +function clearTryToFinish( + store: AgentSessionRecordStore, + sessionId: string, + callerKey: string, + clearFingerprint: string, + operationId: string +): string { + const committed = store.getRecord(sessionId)?.conversationCommand + let earliest: string | null = null + for (const row of store.listOperationRows()) { + if (row.callerKey !== callerKey || row.fingerprint !== clearFingerprint) { + continue + } + // The record also names a commit whose ledger settlement a crash lost. + if ( + row.outcome.status === 'succeeded' || + (committed?.phase === 'committed' && + committed.callerKey === callerKey && + committed.operationId === row.operationId) + ) { + earliest = null + } else if (earliest === null) { + const start = store.getOperationRow( + callerKey, + clearReplacementIds(sessionId, callerKey, row.operationId).attachOperationId + ) + // Only a start that succeeded left a conversation to finish; replaying any other repeats it. + if (start?.outcome.status === 'succeeded') { + earliest = row.operationId + } + } + } + return earliest ?? operationId +} + +/** Another caller's uncommitted /clear holds a replacement whose agent is running. One whose stop + * is merely unproven gates nothing: only that caller's own start would ever settle it. */ +function otherCallersClearIsLive( + store: AgentSessionRecordStore, + sessionId: string, + callerKey: string, + clearFingerprint: string +): boolean { + return store.listOperationRows().some((row) => { + if (row.callerKey === callerKey || row.fingerprint !== clearFingerprint) { + return false + } + const replacement = store.getRecord( + clearReplacementIds(sessionId, row.callerKey, row.operationId).sessionId + ) + return replacement !== null && agentSessionLeaseAdmitsWriter(replacement.lease) + }) +} + export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, host: Pick, @@ -114,61 +169,53 @@ export function runStructuredConversationCommand( return outcome.conversationCommand } const prior = matching() - if (prior?.phase === 'committed') { - return prior - } - if (command === 'compact' && prior && outcome.status !== 'unknown') { - return { - command, - state: 'unknown', - error: 'Compaction completion is unconfirmed; it was not run again.' - } - } - return outcome.status === 'succeeded' && command === 'compact' - ? { command, state: 'completed' } - : null + return prior?.phase === 'committed' ? prior : null }, - rerunWhenReplayMissing: () => command === 'clear' && matching()?.phase === 'prepared', + // Nothing the chat reads is written before the commit, so a clear with no committed answer + // changed nothing and runs again, finishing the replacement its earliest try started. + rerunWhenReplayMissing: () => command === 'clear', run: async (ctx) => { await host.flushStreamedEvents(sessionId) const record = store.getRecord(sessionId)! - const prior = matching() - const blocked = - prior?.phase === 'prepared' && command === 'clear' - ? null - : conversationCommandBlocked(ctx, record, context.readChildWork(sessionId)) + const blocked = conversationCommandBlocked(ctx, record, context.readChildWork(sessionId)) if (blocked) { return { ok: false, refusal: blocked } } - const replacementSessionId = - command === 'clear' - ? (prior?.replacementSessionId ?? - `clear-${createHash('sha256') - .update(JSON.stringify([sessionId, caller.callerKey, clientOperationId])) - .digest('hex') - .slice(0, 40)}`) - : undefined - const prepared = { + let ids: ReturnType | undefined + if (command === 'clear') { + const clearFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId, + fields: { command } + }) + if (otherCallersClearIsLive(store, sessionId, caller.callerKey, clearFingerprint)) { + return { ok: false, refusal: conversationCommandInFlight() } + } + ids = clearReplacementIds( + sessionId, + caller.callerKey, + clearTryToFinish( + store, + sessionId, + caller.callerKey, + clearFingerprint, + clientOperationId + ) + ) + } + const replacementSessionId = ids?.sessionId + const base = { command, runtimeFence: ctx.fence, operationId: clientOperationId, callerKey: caller.callerKey, - phase: 'prepared' as const, - state: 'unknown' as const, ...(replacementSessionId ? { replacementSessionId } : {}) } - await store.setConversationCommand(sessionId, ctx.fence, prepared) - let failure: AgentSessionFailureFact | undefined - if (command === 'clear' && replacementSessionId) { + if (ids) { const attach: AgentSessionAttachParams = { envelope: { - sessionId: replacementSessionId, - clientOperationId: `${parseAgentSessionOperationTimestamp(clientOperationId)}-${createHash( - 'sha256' - ) - .update(JSON.stringify([sessionId, caller.callerKey, clientOperationId])) - .digest('hex') - .slice(0, 32)}`, + sessionId: ids.sessionId, + clientOperationId: ids.attachOperationId, expectedRuntimeFence: null, payloadFingerprint: '' }, @@ -183,20 +230,28 @@ export function runStructuredConversationCommand( } attach.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', - sessionId: replacementSessionId, + sessionId: ids.sessionId, fields: attachFingerprintFields(attach) }) const acquired = await host.attach(caller, attach) - if (!acquired.ok) { + const replacement = store.getRecord(ids.sessionId) + // An earlier try started this replacement and a restart or the idle sweep has since + // stopped it: attach can't replay a settled start, but the new conversation is at rest. + const startedAndAtRest = + replacement !== null && + agentSessionLeaseIsReleased(replacement.lease) && + store.getOperationRow(caller.callerKey, attach.envelope.clientOperationId)?.outcome + .status === 'succeeded' + if (!acquired.ok && !startedAndAtRest) { if ( !isDefinitiveAgentSessionCreateRefusal(acquired.refusal.code) && - store.getRecord(replacementSessionId)?.lease.claimStatus !== 'released' + replacement?.lease.claimStatus !== 'released' ) { throw new Error(acquired.refusal.message) } // The refusal's message is Orca's log text; the result keeps its situation instead. const failed = { - ...prepared, + ...base, replacementSessionId: undefined, phase: 'committed' as const, state: 'completed' as const, @@ -211,78 +266,20 @@ export function runStructuredConversationCommand( await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } } - } else { - if (!ctx.adapter.compact) { - throw new Error('Compaction is unavailable for this provider.') - } - const identity = { - provider: 'orca' as const, - clientMessageId: `compact:${clientOperationId}` - } - await ctx.journal.appendItem( - identity, - { - kind: 'status', - text: 'Compacting conversation…', - turnLifecycle: { turnId: `compact:${clientOperationId}`, state: 'running' } - }, - { fence: ctx.fence } - ) - try { - failure = compactionFailure( - await ctx.adapter.compact({ - turnId: `compact:${clientOperationId}`, - sessionId, - fence: ctx.fence, - onLateResult: (result) => - context.serialize(sessionId, async () => { - if ( - matching()?.phase !== 'prepared' || - context.sessions.get(sessionId)?.journal !== ctx.journal - ) { - return - } - await host.flushStreamedEvents(sessionId) - const late = compactionFailure(result) - await ctx.journal.appendItem(identity, compactionStatusBody(late), { - fence: ctx.fence - }) - await store.setConversationCommand(sessionId, ctx.fence, { - ...prepared, - phase: 'committed', - state: 'completed', - ...conversationCommandFailure(late) - }) - await store.recordOperationOutcome({ - callerKey: caller.callerKey, - operationId: clientOperationId, - outcome: { - status: 'succeeded', - sessionId, - conversationCommand: matching()! - } - }) - }) - }) - ) - await host.flushStreamedEvents(sessionId) - } catch (cause) { - await ctx.journal.appendItem( - identity, - compactionStatusBody(agentSessionFailureFact('compactionUnconfirmed')), - { fence: ctx.fence } - ) - throw cause - } - await ctx.journal.appendItem(identity, compactionStatusBody(failure), { - fence: ctx.fence + // Carry the source's drafts to the replacement, the same for every client version: + // the cards stay visible where the user now is, and no text rides the wire. + // Bookkeeping — a failure is reported and never fails the clear. + await carryQueuedMessagesToClearReplacement(ctx, { + replacementSessionId: ids.sessionId, + replacementJournal: context.sessions.get(ids.sessionId)?.journal, + callerKey: caller.callerKey, + operationId: clientOperationId }) } const completed = { - ...prepared, + ...base, phase: 'committed' as const, - state: 'completed' as const, - ...conversationCommandFailure(failure) + state: 'completed' as const } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts new file mode 100644 index 00000000000..28eae2dff68 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -0,0 +1,751 @@ +// `/compact` travels the send path: accepted as the user's message, carried out by the delivery loop +// as a turn of its own, settled by re-reading the journal. Each case reads what a subscriber that +// was open before the command saw, or the journal a client would load. + +import { beforeEach, expect, it, vi, type Mock } from 'vitest' +import { + AgentJournalSubmissionSchema, + isAdmissibleAgentJournalItemBody +} from '../../../shared/agent-session-journal-schemas' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' + +let state: ReturnType +let compact: Mock> +let closeSession: Mock> + +beforeEach(() => { + state = hostTestState() + // Codex's ack: the provider took the command, which its translator ends later. + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + closeSession = vi.fn(async () => true) + Object.assign(state.host.deps.adapter, { compact, closeSession }) +}) + +/** What the child's journal translator writes when the provider ends the command: the command's + * one result row and its turn's end, in one batch. */ +function finish(result: StructuredConversationCommandOutcome): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + const row: AgentJournalItemBody | null = + result.outcome === 'success' + ? { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + : result.outcome === 'failure' + ? { + kind: 'status', + ...agentSessionFailureWords( + result.failure ?? agentSessionFailureFact('compactionUnconfirmed'), + { surface: 'row' } + ), + tone: 'error' + } + : null + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + ...(row + ? [{ kind: 'item' as const, identity: command.resultIdentity, body: row, turnScope }] + : []), + { + kind: 'item', + identity: command.identity, + body: { + ...command.running, + state: result.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: result.outcome, + completedAt: HOST_TEST_NOW + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +/** Claude's words for a compaction it refused, as its frames carry them. */ +const NOT_ENOUGH = { text: 'Not enough messages to compact.', audience: 'person' as const } + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function subscribe(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await state.host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events +} + +/** One line per journal fact a subscriber received, in delivery order. */ +function frames(events: AgentSessionSubscribeEvent[]): string[] { + return events.flatMap((event) => + event.type === 'batch' + ? [ + ...event.batch.items.map(describeItem), + ...event.batch.submissions.map( + (entry) => `submission:${entry.dispatchState}${entry.handedOverAt ? ':handed' : ''}` + ) + ] + : [] + ) +} + +function describeItem(item: AgentJournalRenderItem): string { + const turn = readAgentJournalTurn(item.body) + if (turn) { + return `turn:${turn.state}${turn.outcome ? `:${turn.outcome}` : ''}` + } + return item.body.kind === 'status' ? `status:${item.body.text}` : item.body.kind +} + +async function journal() { + return state.host.journalSnapshot(SESSION) +} + +async function commandTurn(clientMessageId: string) { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + return (await journal()).items.find((item) => item.itemId === itemId) +} + +it('answers at handover, then journals its own entry, turn and result (B1, B16)', async () => { + await attach() + const events = await subscribe() + const params = compactParams() + const cmid = params.envelope.clientOperationId + + // The reply means "started"; the provider has not finished. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { command: 'compact', state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('completed') + ) + // A client saw the command working before it saw the command's end. + const facts = frames(events) + const running = facts.indexOf('turn:running') + const ended = facts.indexOf('turn:completed:success') + expect(running, facts.join('\n')).toBeGreaterThanOrEqual(0) + expect(ended).toBeGreaterThan(running) + expect(facts.indexOf('status:Context compacted')).toBeGreaterThan(running) + // No turn row was ever overwritten by another body kind. + const turnKey = structuredAgentSessionCommandTurn(cmid).itemId + for (const event of events) { + for (const item of event.type === 'batch' ? event.batch.items : []) { + expect(item.itemId !== turnKey || item.body.kind === 'turn').toBe(true) + } + } + const snapshot = await journal() + const entry = snapshot.items.find((item) => item.body.kind === 'message') + expect(entry?.body).toMatchObject({ command: { name: 'compact' } }) + expect(entry?.turnScope).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + const result = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.presentation === 'compaction' + ) + expect(result?.turnScope).toEqual({ kind: 'turn', turnItemId: turnKey }) + const submission = snapshot.submissions.find((item) => item.clientMessageId === cmid) + expect(submission).toMatchObject({ dispatchState: 'accepted', providerItemId: null }) + // An older client's schemas take the accepted submission with no provider item. + expect(AgentJournalSubmissionSchema.safeParse(submission).success).toBe(true) + expect(isAdmissibleAgentJournalItemBody(entry!.body)).toBe(true) + expect(state.dispatch).not.toHaveBeenCalled() +}) + +it('replays the reply for the same operation without running it again (B16)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('holds messages sent during the command and delivers them after it, in order (B2)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('first'))).resolves.toMatchObject({ ok: true }) + await expect(state.host.send(CALLER, sendParams('second'))).resolves.toMatchObject({ ok: true }) + // Nothing is handed over while the command's turn runs. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(state.dispatch).not.toHaveBeenCalled() + + finish({ + outcome: 'failure', + failure: agentSessionFailureFact('compactionFailed', { detail: NOT_ENOUGH }) + }) + + // Delivered even though the command failed. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2)) + expect(state.dispatch.mock.calls.map(([input]) => input.body.blocks)).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'second' }] + ]) + const turn = await commandTurn(params.envelope.clientOperationId) + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'completed', outcome: 'failure' }) + const error = (await journal()).items.find( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + expect(error?.body).toMatchObject({ + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH } + }) +}) + +it('hands over a message held behind the command when the command ends just as the loop stops for it', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const activeTurnId = live.activeTurnId + let ended = false + vi.spyOn(live, 'activeTurnId').mockImplementation(() => { + const read = activeTurnId() + // The provider's end lands the moment the loop's own step reads the command as running and + // stops; no other reader's view of it matters here. + if ( + !ended && + read?.startsWith('compact:') && + new Error('who reads').stack?.includes('StructuredAgentSessionDeliveryLoop.prepare') + ) { + ended = true + finish({ outcome: 'success' }) + } + return read + }) + + await expect(state.host.send(CALLER, sendParams('held'))).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(ended).toBe(true) +}) + +it('settles a command the provider refused as a failure with its reason, and moves on (B3)', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await state.host.send(CALLER, sendParams('after the refusal')) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + const snapshot = await journal() + // The message was not sent, in the provider's words; the command's row says the compaction failed. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message: Not enough messages to compact.', + rejection: { kind: 'providerRejected', detail: NOT_ENOUGH } + }) + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH }, + tone: 'error' + }, + turnScope: { kind: 'turn', turnItemId: structuredAgentSessionCommandTurn(cmid).itemId } + }) + ]) +}) + +it('says only that the compaction failed when the provider refused it without words', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }) + }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + + await vi.waitFor(async () => + expect( + (await journal()).items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + ) +}) + +it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { + await attach() + const events = state.acquire.mock.calls.at(-1)?.[0].events + // The provider starts a turn of its own after acceptance, before the command is handed over. + Object.assign(state.host.deps.adapter, { + awaitStarted: vi.fn(async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + }) + const params = compactParams() + + const refused = { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + } + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() + expect( + (await journal()).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "This command didn't run. Try it again.", + rejection: refused + }) +}) + +it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { + await attach() + await state.host.close(SESSION) + state.acquire.mockRejectedValue(new Error('not signed in')) + const params = compactParams() + + // The next step is the command again, not a message. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { + state: 'completed', + error: "Codex couldn't restart. Run /compact again.", + failure: { kind: 'restartFailed' } + } + }) + const snapshot = await journal() + expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) + // The start's own row, in the words the command's message was refused with. + expect( + snapshot.items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: "Codex couldn't restart. Run /compact again.", + failure: expect.objectContaining({ kind: 'restartFailed' }), + tone: 'error' + } + ]) + expect( + snapshot.submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Run /compact again." + }) + expect(compact).not.toHaveBeenCalled() +}) + +function stop(turnId: string) { + return state.host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId + }) +} + +it('leaves the command to the provider when it takes the Stop, and ends it as cancelled (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + // The interrupt was taken, not answered: the command runs until the provider ends it. + expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) + expect(closeSession).not.toHaveBeenCalled() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + finish({ outcome: 'cancellation' }) + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + // A cancellation writes no result row. + expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) +}) + +it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + // The provider takes the interrupt and then never answers it. + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + expect(closeSession).not.toHaveBeenCalled() + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(state.cancelTurn).toHaveBeenCalledOnce() + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') +}) + +it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { + await attach() + // Codex before it opened the command's turn, or Claude refusing the interrupt. + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + + await expect(stop(structuredAgentSessionCommandTurn(cmid).turnId)).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) + + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + const notes = (await journal()).items.filter((item) => item.body.kind === 'status') + expect(notes.map((item) => item.body.kind === 'status' && item.body.text)).toEqual([ + 'Cancellation requested.' + ]) + // The next message starts a child of its own. + await state.host.send(CALLER, sendParams('after the stop')) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) +}) + +it('does not stop the child for a Stop naming a command that already ended (B4)', async () => { + await attach() + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const { itemId, turnId } = structuredAgentSessionCommandTurn(params.envelope.clientOperationId) + await vi.waitFor(async () => + expect( + readAgentJournalTurn((await journal()).items.find((item) => item.itemId === itemId)?.body) + ?.state + ).toBe('completed') + ) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(closeSession).not.toHaveBeenCalled() +}) + +it("answers a refused command's message before ending its turn, so a crash between them leaves a turn the sweep settles (B4)", async () => { + await attach() + // The one command end the host still writes: the provider refused it. A provider's own end is + // one batch its translator writes, with nothing to split. + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + const crash = vi + .spyOn(live, 'appendLifecycleBatch') + .mockImplementation((input) => + input.settlementId.startsWith('command-settled:') + ? Promise.reject(new Error('host crashed')) + : appendLifecycleBatch(input) + ) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(crash).toHaveBeenCalled()) + crash.mockRestore() + + // Never an ended turn whose message still reads as in flight. + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState + ).toBe('rejected') + await settleStaleStructuredAgentSessionState({ + journal: live, + sessionId: SESSION, + fence: state.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: null, + deathEvidence: null + }) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') +}) + +it('counts a message held behind the command from its handover, not its send', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const sent = await state.host.send(CALLER, sendParams('held')) + expect(sent.ok).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 20)) + finish({ outcome: 'success' }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + const [dispatched] = state.dispatch.mock.calls[0]! + const submission = (await journal()).submissions.find( + (entry) => entry.clientMessageId === dispatched.clientMessageId + ) + expect(submission?.handedOverAt).toBeGreaterThan(submission!.submittedAt) + expect(dispatched.requestedAt).toBe(submission?.handedOverAt) +}) + +it('settles a command whose adapter call threw after the start as unknown (B4)', async () => { + await attach() + compact.mockImplementation(() => { + throw new Error('codex app-server session is not live') + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') + ) + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid) + ).toMatchObject({ dispatchState: 'unknown', reason: 'codex app-server session is not live' }) +}) + +it('writes one exit row when the child dies mid-command, and the loop writes nothing but moves on (B4)', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // The next child resumes the thread, as a real one does. + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('queued behind it'))).resolves.toMatchObject({ + ok: true + }) + + // The adapter never answers the command: the host's own record of the child's end is enough. + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + // Released from the command, the loop starts a child and delivers what waited behind it. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + const snapshot = await journal() + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) + expect(snapshot.items.some((item) => item.itemId.includes('command-result'))).toBe(false) + // Codex acknowledged the command, so the message was delivered; only its turn was cut short. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState).toBe( + 'accepted' + ) +}) + +it('delivers the next message after a command whose child died and whose settlement could not be written', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + vi.spyOn(live, 'appendLifecycleBatch').mockImplementation((input) => + input.settlementId.includes('provider-exit:') + ? Promise.reject(new Error('disk full')) + : appendLifecycleBatch(input) + ) + + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(SESSION)?.child).toBeNull()) + // The exit it recorded settles what the failed write left running, so the command holds nothing. + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + + await expect(state.host.send(CALLER, sendParams('after it'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') +}) + +it("ignores an older build's unconfirmed compaction record, and answers its operation without rerunning it (B15)", async () => { + await attach() + const older = compactParams() + // An older build admitted this operation and died before recording its outcome. + await state.store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: older.envelope, + hostFingerprint: older.envelope.payloadFingerprint, + now: HOST_TEST_NOW + }) + await state.store.setConversationCommand( + SESSION, + state.store.getRecord(SESSION)!.lease.runtimeFence, + { + command: 'compact', + runtimeFence: state.store.getRecord(SESSION)!.lease.runtimeFence, + operationId: older.envelope.clientOperationId, + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + } + ) + + await expect(state.host.send(CALLER, sendParams('still works'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await expect(state.host.conversationCommand(CALLER, compactParams())).resolves.toMatchObject({ + ok: true, + value: { state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await vi.waitFor(async () => + expect((await journal()).submissions.every((entry) => entry.dispatchState === 'accepted')).toBe( + true + ) + ) + + await expect(state.host.conversationCommand(CALLER, older)).resolves.toMatchObject({ + ok: true, + value: { + state: 'unknown', + error: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' } + } + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('never lets a provider echo alias the command entry', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const events = state.acquire.mock.calls.at(-1)?.[0].events + const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } + events?.appendItem( + echo, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await state.host.flushStreamedEvents(SESSION) + expect((await journal()).items.some((item) => item.itemId === agentJournalItemKey(echo))).toBe( + true + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts new file mode 100644 index 00000000000..066afdc08db --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -0,0 +1,161 @@ +// `/compact` sent through the command RPC: accepted into the conversation as the user's message, +// and answered once the delivery loop hands it over. + +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' +import { + agentSessionFailureFact, + readAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + conversationCommandPlan, + type ConversationCommandAcceptance +} from './structured-agent-session-mutation-plans' +import { + sendPreparation, + structuredAgentSessionFailureWordsContext, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import { + conversationCommandFailure, + type ConversationCommandParams +} from './structured-conversation-command' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +/** + * `/compact` from a client that asks through the command RPC: accepted into the conversation like + * any message, and answered once it is handed over — the command has started, not finished. Its + * end reaches the chat as its own turn and result row. + */ +export async function runStructuredCompaction( + context: StructuredAgentSessionMutationContext, + host: Pick, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams +): Promise> { + const { sessionId, clientOperationId } = params.envelope + // An older build ran this operation id and recorded it on the session: answered, never rerun. + const priorRecord = (): AgentSessionConversationCommandResult | null => { + const prior = context.deps.store.getRecord(sessionId)?.conversationCommand + if (prior?.operationId !== clientOperationId || prior.callerKey !== caller.callerKey) { + return null + } + return prior.phase === 'committed' + ? prior + : { + command: 'compact', + state: 'unknown', + ...conversationCommandFailure(agentSessionFailureFact('compactionUnconfirmed')) + } + } + const accepted = await acceptStructuredConversationCommand(context, caller, params, priorRecord) + if (!accepted.ok) { + return accepted + } + if ('recorded' in accepted.value) { + return { ...accepted, value: accepted.value.recorded } + } + const settled = await host.waitForSendSettlement(sessionId, accepted.value.clientMessageId, { + until: 'handed-over', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS + }) + return { + ...accepted, + ...(settled ? { cursor: settled.cursor } : {}), + // A /compact is a command send, never a queued draft, so its settlement always carries the submission. + value: compactionReply( + settled && 'submission' in settled.value ? settled.value.submission : undefined, + { + ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), + command: 'compact' + } + ) + } +} + +/** The reply shape clients already read: `completed` is now "started". */ +function compactionReply( + submission: AgentJournalSubmission | undefined, + context: AgentSessionFailureWordsContext +): AgentSessionConversationCommandResult { + const error = (reason: string | null, fallback: string) => (reason ?? fallback).slice(0, 4096) + if (!submission || isQueuedAgentJournalSubmission(submission)) { + return { command: 'compact', state: 'unknown', error: 'The command has not started yet.' } + } + if (submission.dispatchState === 'rejected') { + // The message's own fact, in the words its row shows; a row from an older host keeps its reason. + const rejection = readAgentSessionFailureFact(submission.rejection) + return rejection + ? { + command: 'compact', + state: 'completed', + ...conversationCommandFailure(rejection, context) + } + : { + command: 'compact', + state: 'completed', + error: error(submission.reason, 'The command was not run.') + } + } + return submission.dispatchState === 'unknown' + ? { + command: 'compact', + state: 'unknown', + error: error(submission.reason, 'The command may not have run.') + } + : { command: 'compact', state: 'completed' } +} + +/** A conversation command, accepted into the queue as the user's message. */ +function acceptStructuredConversationCommand( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope }, + priorRecord: () => AgentSessionConversationCommandResult | null +): Promise> { + const plan = conversationCommandPlan({ envelope: params.envelope, priorRecord }) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + run: async (ctx) => { + const record = context.deps.store.getRecord(ctx.sessionId) + const refusal = + record && + conversationCommandBlocked( + ctx, + record, + context.readChildWork(ctx.sessionId), + context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest' + ) + const blocked = + structuredAgentSessionSendBlock(record) ?? + (refusal ? { ok: false as const, refusal } : null) + if (blocked) { + return blocked + } + const accepted = await plan.run(ctx) + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted + } + }, + sendPreparation(context, params.envelope) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 42b6e8dd91d..b572e51e5f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,5 +1,7 @@ -import { restoreRewindJournalBody } from './structured-rewind-journal-body' -import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, @@ -83,11 +85,9 @@ export async function recoverStructuredRewind( beforeTurnId: target.turnId }) if (!recovered?.ok) { - if ( - recovered?.reason === 'provider-refused' && - rewind.phase === 'prepared' && - !rewind.providerApplied - ) { + // The target is still in the provider's history, and the journal is replaced only once the + // revert is proven, so both still hold it even when the provider acknowledged the revert. + if (recovered?.reason === 'provider-refused' && rewind.phase === 'prepared') { await persistRewindRecord(store, sessionId, fence, { ...rewind, phase: 'refused', @@ -141,13 +141,7 @@ export async function recoverStructuredRewind( if (rewind.phase !== 'provider-succeeded') { return } - const replacement = rewind.retained.map((item) => { - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - throw new Error('agent_session_rewind:invalid-retained-identity') - } - return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt } - }) + const replacement = rewind.retained.map(retainedRowReplacement) // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch if ( diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts new file mode 100644 index 00000000000..5b47be7f9f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AgentSessionRewindRecordSchema, + type AgentSessionRewindRecord +} from '../../../shared/agent-session-rewind' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import type * as RewindJournalBody from './structured-rewind-journal-body' + +vi.mock('./structured-rewind-journal-body', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, restoreRewindJournalBody: vi.fn(actual.restoreRewindJournalBody) } +}) + +type Retained = AgentSessionRewindRecord['retained'][number] + +const codexKey = (turnId: string, ordinal: number) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal }) +const orcaKey = (clientMessageId: string) => + agentJournalItemKey({ provider: 'orca', clientMessageId }) + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function retained(itemId: string, body: AgentJournalItemBody, extra: Partial = {}) { + return { itemId, body, observedAt: 1, ...extra } +} + +/** Provider history carries no scope or producer of its own. */ +function providerItem(itemId: string, body: AgentJournalItemBody): Retained { + return { itemId, body, observedAt: 2 } +} + +const TURN_RECORD = orcaKey('turn-a') +const inTurnA = { kind: 'turn', turnItemId: TURN_RECORD } + +describe('rewind keeps each retained row attributed', () => { + it("keeps a held row's scope and producer, a subagent's row staying the subagent's", () => { + const own = codexKey('a', 1) + const child = codexKey('a', 2) + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(own, prose('own'), { turnScope: inTurnA }), + retained(child, prose('child'), { + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(own, prose('own')), + providerItem(child, prose('child')) + ]) + expect(merged.find((item) => item.itemId === own)).toMatchObject({ turnScope: inTurnA }) + expect(merged.find((item) => item.itemId === own)?.agentId).toBeUndefined() + expect(merged.find((item) => item.itemId === child)).toMatchObject({ + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent', + observedAt: 2 + }) + }) + + it("splices a command's turn, result and entry back, since provider history holds none", () => { + const before = codexKey('a', 1) + const commandTurn = orcaKey('command-turn:cmd-1') + const commandResult = orcaKey('command-result:cmd-1') + const entry = orcaKey('cmd-1') + const reference = [ + retained(before, prose('before')), + retained(entry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + retained(commandTurn, { kind: 'turn', turnId: 'compact:cmd-1', state: 'completed' }), + retained( + commandResult, + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + { turnScope: { kind: 'turn', turnItemId: commandTurn } } + ) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(before, prose('before')) + ]) + expect(merged.map((item) => item.itemId)).toEqual([before, entry, commandTurn, commandResult]) + expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn }) + }) + + it('places a provider item the old epoch never held in the turn record for its provider turn', () => { + const commandTurn = orcaKey('command-turn:cmd-1') + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'completed', + providerTurnId: 'b' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(codexKey('a', 1), prose('in a')), + providerItem(codexKey('b', 1), prose('claimed by the command')), + providerItem(codexKey('c', 1), prose('no record')) + ]) + const scopeOf = (itemId: string) => merged.find((item) => item.itemId === itemId)?.turnScope + expect(scopeOf(codexKey('a', 1))).toEqual(inTurnA) + expect(scopeOf(codexKey('b', 1))).toEqual({ kind: 'turn', turnItemId: commandTurn }) + // No record to join: the rebuilt epoch places it by position. + expect(scopeOf(codexKey('c', 1))).toBeUndefined() + }) +}) + +describe('the rebuilt epoch item for a retained row', () => { + it('passes a placeable scope and a known producer through', () => { + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: inTurnA, + agentId: 'child', + producerKind: 'agent', + attempt: 2 + }) + ) + ).toMatchObject({ turnScope: inTurnA, agentId: 'child', producerKind: 'agent', attempt: 2 }) + }) + + it('drops a scope and a producer kind this build cannot place', () => { + const replacement = retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: { kind: 'future-kind' }, + producerKind: 'future-producer' + }) + ) + expect(replacement.turnScope).toBeUndefined() + expect(replacement.producerKind).toBeUndefined() + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { turnScope: { kind: 'turn' } }) + ).turnScope + ).toBeUndefined() + }) + + it('leaves a row from an older record unscoped, for the rebuild to derive', () => { + const replacement = retainedRowReplacement(retained(codexKey('a', 1), prose('x'))) + expect(replacement).not.toHaveProperty('turnScope') + expect(replacement).not.toHaveProperty('agentId') + }) + + it('still reads a persisted record written before rows carried scope or producer', () => { + const older = { + operationId: 'op-1', + callerKey: 'desktop', + itemId: codexKey('b', 0), + expectedEpoch: 'epoch-1', + phase: 'prepared', + retained: [{ itemId: codexKey('a', 1), body: prose('x'), observedAt: 1 }] + } + expect(AgentSessionRewindRecordSchema.safeParse(older).success).toBe(true) + }) +}) + +describe('rewind merge cost', () => { + it('reads each merged row once to place the provider items it never held', () => { + const turns = 400 + const providerItems = Array.from({ length: turns }, (_, index) => + providerItem(codexKey(`t${index}`, 1), prose(`answer ${index}`)) + ) + const reference = Array.from({ length: turns }, (_, index) => + retained(orcaKey(`turn-${index}`), { + kind: 'turn', + turnId: `t${index}`, + state: 'completed' + }) + ) + vi.mocked(restoreRewindJournalBody).mockClear() + + const merged = mergeRetainedHostLifecycleRows(reference, providerItems) + + expect(merged.find((item) => item.itemId === codexKey(`t${turns - 1}`, 1))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: orcaKey(`turn-${turns - 1}`) } + }) + // A scan per provider item reads every merged row for each one: turns² reads. + expect(vi.mocked(restoreRewindJournalBody).mock.calls.length).toBeLessThanOrEqual(merged.length) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index 35307566599..6496518b50b 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,18 +1,30 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the -// provider item each one followed. +// provider item each one followed. Provider items carry neither turn scope nor producer, so each +// keeps the ones its retained row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' type RetainedRow = AgentSessionRewindRecord['retained'][number] export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { + const identity = parseAgentJournalItemKey(item.itemId) return ( readAgentJournalTurn(item.body as AgentJournalItemBody) !== null || - parseCodexGoalJournalItemId(item.itemId) !== null + parseCodexGoalJournalItemId(item.itemId) !== null || + // A conversation command's entry and result: the provider's history holds neither. + (item.body.kind === 'message' && item.body.command !== undefined) || + (identity?.provider === 'orca' && identity.clientMessageId.startsWith('command-')) ) } @@ -22,6 +34,7 @@ export function mergeRetainedHostLifecycleRows( providerItems: readonly RetainedRow[] ): RetainedRow[] { const spineIndex = new Map(providerItems.map((item, index) => [item.itemId, index])) + const held = new Map(reference.map((item) => [item.itemId, item])) const rowsAfter = new Map() let anchor = -1 for (const item of reference) { @@ -32,6 +45,77 @@ export function mergeRetainedHostLifecycleRows( } } const merged = [...(rowsAfter.get(-1) ?? [])] - providerItems.forEach((item, index) => merged.push(item, ...(rowsAfter.get(index) ?? []))) - return merged + providerItems.forEach((item, index) => + merged.push(withHeldAttribution(item, held.get(item.itemId)), ...(rowsAfter.get(index) ?? [])) + ) + const turnRecords = turnRecordsByProviderTurn(merged) + return merged.map((item) => + held.has(item.itemId) || item.turnScope ? item : withProviderTurnScope(item, turnRecords) + ) +} + +/** The rebuilt epoch's item for one retained row, with the scope and producer it was written with. */ +export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLinkage & { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + observedAt: number + turnScope?: AgentJournalTurnScope +} { + const identity = parseAgentJournalItemKey(row.itemId) + if (!identity) { + throw new Error('agent_session_rewind:invalid-retained-identity') + } + const { agentId, parentAgentId, providerParentRef, producerKind, attempt, turnScope } = row + const scope = + turnScope?.kind === 'turn' && turnScope.turnItemId + ? { kind: 'turn' as const, turnItemId: turnScope.turnItemId } + : turnScope?.kind === 'thread' + ? { kind: 'thread' as const } + : undefined + return { + identity, + body: restoreRewindJournalBody(row.body), + observedAt: row.observedAt, + ...(scope ? { turnScope: scope } : {}), + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(providerParentRef === undefined ? {} : { providerParentRef }), + ...(producerKind === 'agent' || producerKind === 'background' ? { producerKind } : {}), + ...(attempt === undefined ? {} : { attempt }) + } +} + +function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { + if (!held) { + return item + } + const { itemId: _itemId, body: _body, observedAt: _observedAt, ...attribution } = held + return { ...item, ...attribution } +} + +/** Provider turn id → the item id of its turn record: the turn's own, or the command turn that + * claimed it. The first record wins, as a scan from the top would find it. */ +function turnRecordsByProviderTurn(merged: readonly RetainedRow[]): ReadonlyMap { + const records = new Map() + for (const candidate of merged) { + const turn = readAgentJournalTurn(restoreRewindJournalBody(candidate.body)) + for (const turnId of [turn?.turnId, turn?.providerTurnId]) { + if (turnId !== undefined && !records.has(turnId)) { + records.set(turnId, candidate.itemId) + } + } + } + return records +} + +/** A provider item the old epoch never held joins the turn record for its provider turn. + * Otherwise the rebuild places it. */ +function withProviderTurnScope( + item: RetainedRow, + turnRecords: ReadonlyMap +): RetainedRow { + const identity = parseAgentJournalItemKey(item.itemId) + const turnItemId = + identity?.provider === 'codex' && identity.turnId ? turnRecords.get(identity.turnId) : undefined + return turnItemId ? { ...item, turnScope: { kind: 'turn', turnItemId } } : item } diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts deleted file mode 100644 index 8936a4a28d0..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from './structured-session-compaction' - -describe('structured compaction lifecycle', () => { - it('waits beyond the Codex acknowledgment and ignores other threads', async () => { - const tracker = new StructuredSessionCompaction() - const finished = vi.fn() - const result = tracker - .run('session', 'thread', async () => undefined) - .then((value) => { - finished() - return value - }) - await Promise.resolve() - tracker.codex('session', 'turn/started', { threadId: 'other', turn: { id: 'foreign' } }) - tracker.codex('session', 'turn/completed', { - threadId: 'other', - turn: { id: 'foreign', status: 'completed' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/started', { threadId: 'thread', turn: { id: 'compact-turn' } }) - tracker.codex('session', 'item/completed', { - threadId: 'thread', - item: { type: 'contextCompaction' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/completed', { - threadId: 'thread', - turn: { id: 'compact-turn', status: 'completed' } - }) - await expect(result).resolves.toEqual({ outcome: 'compacted' }) - }) - - it('observes notifications arriving before the request acknowledgment', async () => { - const tracker = new StructuredSessionCompaction() - await expect( - tracker.run('s', 't', async () => { - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } - }) - }) - ).resolves.toEqual({ - outcome: 'failed', - detail: { text: 'Unavailable', audience: 'person' } - }) - }) - - it.each(['success', 'failed'])( - 'uses Claude compact_result %s rather than result subtype', - async (state) => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'provider', async () => {}) - tracker.claude('s', { - type: 'system', - subtype: 'status', - session_id: 'provider', - compact_result: state, - compact_error: 'Not enough messages to compact.' - }) - tracker.claude('s', { - type: 'result', - subtype: 'success', - session_id: 'provider', - result: '' - }) - await expect(result).resolves.toEqual( - state === 'success' - ? { outcome: 'compacted' } - : { - outcome: 'failed', - detail: { text: 'Not enough messages to compact.', audience: 'person' } - } - ) - } - ) - - it('cleans up on provider exit and permits another operation', async () => { - const tracker = new StructuredSessionCompaction() - const pending = tracker.run('s', 'p', async () => {}) - tracker.ended('s') - await expect(pending).resolves.toEqual({ outcome: 'unconfirmed' }) - const next = tracker.run('s', 'p', async () => { - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - }) - await expect(next).resolves.toEqual({ outcome: 'compacted' }) - }) - it('reconciles a terminal frame after timeout without repeating the provider request', async () => { - vi.useFakeTimers() - try { - const tracker = new StructuredSessionCompaction(10) - const late = vi.fn(async () => {}) - const invoke = vi.fn(async () => undefined) - const result = tracker.run('s', 'p', invoke, late) - const rejected = expect(result).rejects.toThrow('unconfirmed') - await vi.advanceTimersByTimeAsync(11) - await rejected - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - expect(late).toHaveBeenCalledWith({ outcome: 'compacted' }) - expect(invoke).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('does not mistake an unrelated completed turn for compaction', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => undefined) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) - await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) - }) - - it('fails, not merely unconfirmed, a Codex compaction turn that did not complete', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => undefined) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'interrupted' } - }) - await expect(result).resolves.toEqual({ outcome: 'failed' }) - }) - - it('leaves a Claude turn that ended without confirming compaction unconfirmed', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'p', async () => {}) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) - }) - - it('fails a Claude compaction whose turn ended in error', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'p', async () => {}) - tracker.claude('s', { type: 'result', subtype: 'error_during_execution', session_id: 'p' }) - await expect(result).resolves.toEqual({ outcome: 'failed' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts deleted file mode 100644 index 5f5c88d0b07..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' - -/** How a compaction ended. `unconfirmed`: the provider never said whether it compacted, so it may - * have. `detail` is the provider's own words for a failure, when it gave any. */ -export type StructuredSessionCompactionResult = - | { outcome: 'compacted' } - | { outcome: 'failed'; detail?: ProviderDiagnostic } - | { outcome: 'unconfirmed' } - -/** A compaction that never began: refused before it was sent, or by the provider at the request. */ -export type StructuredSessionCompactionRefusal = Extract< - StructuredSessionCompactionResult, - { outcome: 'failed' } -> - -function compactionFailed( - detail: ProviderDiagnostic | undefined -): StructuredSessionCompactionRefusal { - return { outcome: 'failed', ...(detail ? { detail } : {}) } -} - -type PendingCompaction = { - identity: string - commandTurnId?: string - turnId?: string - /** The provider reported the compaction failed, with its words when it gave any. */ - failed?: { detail?: ProviderDiagnostic } - compacted: boolean - finish: (result: StructuredSessionCompactionResult) => void -} - -function record(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {} -} - -export function isCodexCompactionComplete(method: string, params: unknown): boolean { - return ( - method === 'thread/compacted' || - (method === 'item/completed' && record(record(params).item).type === 'contextCompaction') - ) -} - -/** A receipt is not completion; keep listening through the provider's terminal frame. */ -export class StructuredSessionCompaction { - private readonly pending = new Map() - constructor(private readonly timeoutMs = 180_000) {} - - async run( - sessionId: string, - identity: string, - invoke: () => Promise, - onLateResult?: (result: StructuredSessionCompactionResult) => Promise, - commandTurnId?: string - ): Promise { - if (this.pending.has(sessionId)) { - throw new Error('Compaction is already running.') - } - let timer: ReturnType - let expired = false - const completion = new Promise((resolve, reject) => { - const finish = (result: StructuredSessionCompactionResult) => { - this.pending.delete(sessionId) - if (expired && onLateResult) { - void onLateResult(result).catch((error) => - console.warn('Could not persist late compaction completion', error) - ) - } - resolve(result) - } - this.pending.set(sessionId, { - identity, - commandTurnId, - compacted: false, - finish - }) - timer = setTimeout(() => { - expired = true - reject(new Error('Compaction completion is unconfirmed.')) - }, this.timeoutMs) - timer.unref?.() - }) - // Observe rejection even while invoke is waiting for its own receipt. - void completion.catch(() => {}) - try { - const refusal = await invoke() - if (refusal) { - this.pending.get(sessionId)?.finish(compactionFailed(refusal.detail)) - } - return await completion - } catch (error) { - expired = this.pending.has(sessionId) - throw error - } finally { - clearTimeout(timer!) - if (!expired) { - this.pending.delete(sessionId) - } - } - } - - hasPending(sessionId: string): boolean { - return this.pending.has(sessionId) - } - - ownsTurn(sessionId: string, turnId: string): boolean { - return this.pending.get(sessionId)?.commandTurnId === turnId - } - - providerTurnId(sessionId: string, turnId: string): string | undefined { - return this.ownsTurn(sessionId, turnId) ? this.pending.get(sessionId)?.turnId : turnId - } - - ended(sessionId: string): void { - // It may have compacted before it exited. - this.pending.get(sessionId)?.finish({ outcome: 'unconfirmed' }) - } - - codex(sessionId: string, method: string, value: unknown): void { - const pending = this.pending.get(sessionId) - const params = record(value) - if (!pending || params.threadId !== pending.identity) { - return - } - const turn = record(params.turn) - if (method === 'turn/started' && typeof turn.id === 'string') { - pending.turnId = turn.id - } - if (isCodexCompactionComplete(method, params)) { - pending.compacted = true - } - if (method === 'turn/completed' && turn.id === pending.turnId) { - const error = record(turn.error).message - pending.finish( - turn.status !== 'completed' - ? compactionFailed( - typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined - ) - : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } - ) - } - } - - claude(sessionId: string, message: Record): void { - const pending = this.pending.get(sessionId) - if (!pending || message.session_id !== pending.identity) { - return - } - if (message.compact_result === 'failed') { - pending.failed = { - detail: - typeof message.compact_error === 'string' - ? providerDiagnostic(message.compact_error, 'person') - : undefined - } - } - if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { - pending.compacted = true - } - if (message.type === 'result') { - if ( - message.is_error === true || - (typeof message.subtype === 'string' && message.subtype.startsWith('error')) - ) { - pending.failed ??= {} - } - pending.finish( - pending.failed - ? compactionFailed(pending.failed.detail) - : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } - ) - } - } -} diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts index f647579d03e..8d6aa55783f 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.test.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -90,24 +90,6 @@ describe('structuredClaudeMatchesActiveManagedAccount', () => { expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) }) - /** The four states this gate exists to tell apart, pinned together so a change to one is visible - * against the others. */ - it.each([ - ['no managed accounts', [], null, true], - ['accounts present, none active, no WSL account', [account('host-1', 'host')], null, true], - ['host account selected', [account('host-1', 'host')], 'host-1', true], - ['WSL-only, normalized to no host selection', [account('wsl-1', 'wsl')], null, false] - ] as const)('resolves %s', (_name, claudeManagedAccounts, activeId, expected) => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [...claudeManagedAccounts], - activeClaudeManagedAccountIdsByRuntime: { host: activeId, wsl: {} } - }) - ) - ).toBe(expected) - }) - /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the * settings alone cannot distinguish honest deselection from the WSL-only steady state, because * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index e99550c59ea..2375cf02fd4 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -14,7 +14,6 @@ import { utimesSync, writeFileSync } from 'node:fs' -import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -28,9 +27,7 @@ import { OpenCodeHookService, openCode2HookService, _internals, - getOpenCodeFamilyPluginSource, - getOpenCodePluginSource, - getOpenCode2PluginSource + getOpenCodePluginSource } from './hook-service' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' @@ -48,166 +45,6 @@ beforeEach(() => { const { isUsableId, toSafeDirName } = _internals -describe('OpenCode hook plugin source', () => { - it('preserves the public module surface', async () => { - const module = await import('./hook-service') - - expect(Object.keys(module).sort()).toEqual([ - 'OpenCodeHookService', - '_internals', - 'getOpenCode2PluginSource', - 'getOpenCodeFamilyPluginSource', - 'getOpenCodePluginSource', - 'openCode2HookService', - 'openCodeHookService' - ]) - expect(Object.keys(module._internals).sort()).toEqual([ - 'getOpenCode2PluginSource', - 'getOpenCodePluginSource', - 'isUsableId', - 'toSafeDirName' - ]) - }) - - it('keeps family routing and session-start policy separate', () => { - const primarySource = getOpenCodePluginSource() - const familySource = getOpenCodeFamilyPluginSource('/hook/mimo-code', { - emitSessionStart: false - }) - - expect(primarySource).toContain('http://127.0.0.1:${coords.port}/hook/opencode') - expect(primarySource).toContain('post("SessionStart", { sessionID: info.id })') - expect(familySource).toContain('http://127.0.0.1:${coords.port}/hook/mimo-code') - expect(familySource).not.toContain('post("SessionStart", { sessionID: info.id })') - expect(familySource).toContain('export const OrcaOpenCodeStatusPlugin') - }) - - it('generates the OpenCode 2 plugin with its dedicated hook and event family', () => { - const source = getOpenCode2PluginSource() - expect(source).toContain('/hook/opencode2') - expect(source).toContain('session.next.text.delta') - expect(source).toContain('permission.v2.asked') - expect(source).toContain('event.type === "session.next.prompt.admitted"') - expect(source).not.toContain( - 'event.type === "session.next.prompted" || event.type === "session.next.prompt.admitted"' - ) - }) - - it('keeps generated plugin bytes stable across the module split', () => { - const digest = (source: string): string => createHash('sha256').update(source).digest('hex') - - expect(digest(getOpenCodePluginSource())).toBe( - '655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7' - ) - expect( - digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e') - }) - - it('filters child sessions via parentID lookup before forwarding events', () => { - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain('async function isChildSession(client, sessionID)') - expect(source).toContain('walkSessionParents(client, sessionID, controller.signal)') - expect(source).toContain('currentSessionID = session.parentID;') - expect(source).toContain('rememberSessionRoot(id, currentSessionID)') - expect(source).toContain('{ path: { id: sessionID }, signal }') - expect(source).toContain('[{ sessionID }, { signal }]') - expect(source.indexOf('[{ sessionID }, { signal }]')).toBeLessThan( - source.indexOf('{ path: { id: sessionID }, signal }') - ) - expect(source).toContain('return client.session.list({}, { signal });') - expect(source).toContain('return client.session.list({ signal });') - expect(source).toContain('return rootSessionID === null ? null : rootSessionID !== sessionID;') - expect(source).toContain( - 'if (sessionID && (await isChildSession(client, sessionID)) !== false) {' - ) - expect(source).toContain( - 'if (sessionID && (await isChildSession(client, sessionID)) !== false) {\n return;\n }' - ) - }) - - it('still accepts an optional opaque plugin context instead of destructuring', () => { - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain('export const OrcaOpenCodeStatusPlugin = async (_ctx) => {') - expect(source).toContain('const client = _ctx?.client;') - }) - - it('resolves hook coords from the endpoint file before falling back to process.env', () => { - // Why: a forked session freezes the prior Orca's PORT/TOKEN in env; prefer the on-disk endpoint file or it posts to a dead port after restart. - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain('function readEndpointFile()') - expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT') - // Parser accepts both `KEY=VALUE` (Unix) and `set KEY=VALUE` (Windows): - expect(source).toContain('/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/') - expect(source).toContain('function resolveHookCoords()') - // File takes precedence over env — the whole point of v2: - expect(source).toContain( - 'port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT' - ) - expect(source).toContain( - 'token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN' - ) - // post() uses the resolved coords, not a cached-at-startup url: - expect(source).toContain('const coords = resolveHookCoords();') - expect(source).toContain('`http://127.0.0.1:${coords.port}/hook/opencode`') - expect(source).toContain('"X-Orca-Agent-Hook-Token": coords.token') - }) - - it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => { - // Why: cache the parse to avoid a read per streamed Part; inode in the key lets renameSync invalidate it even when mtime/size collide. - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain('let cachedEndpointKey = "";') - expect(source).toContain('let cachedEndpointValues = null;') - expect(source).toContain('const stat = fs.statSync(path);') - expect(source).toContain('const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;') - expect(source).toContain('if (cacheKey === cachedEndpointKey && cachedEndpointValues) {') - expect(source).toContain('return cachedEndpointValues;') - // Stat failure must invalidate the cache, not lock in stale values: - expect(source).toContain('cachedEndpointKey = "";') - expect(source).toContain('cachedEndpointValues = null;') - }) - - it('forwards question.asked as AskUserQuestion so the pane flips to waiting', () => { - // Why: forward question.asked too (not just permission.asked), else the pane stays "working" while the agent idles on a human reply. - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain('event.type === "question.asked"') - expect(source).toContain( - 'event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion"' - ) - expect(source).toContain('await setAttention(') - }) - - it('forwards sessionID on status and message posts for resume metadata', () => { - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain( - '{ role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },' - ) - expect(source).toContain('messageID: pending.messageID,') - expect(source).toContain('sessionID: pending.sessionID,') - expect(source).toContain( - 'await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);' - ) - expect(source).toContain( - 'await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);' - ) - }) - - it('guards endpoint-file parse warnings with a process-lifetime latch', () => { - // Why: ENOENT is normal pre-install (stay silent), but a bad file would spam stderr per post; latch warns once per process. - const source = _internals.getOpenCodePluginSource() - - expect(source).toContain('let warnedBadEndpoint = false;') - expect(source).toContain('err.code !== "ENOENT"') - expect(source).toContain('warnedBadEndpoint = true;') - }) -}) - describe('OpenCode id safety guard', () => { it('accepts the daemon-path sessionId shape (worktreeId@@uuid with ::/...)', () => { // Why: after #1148 pty.ts mints sessionIds like @@ with "::" and a path; the old strict regex rejected every real id. diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 49e4cb9ea98..02a7ce77ca6 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -128,9 +128,9 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' }, - // Residue: keyed by tab id and pruned by neither path, like remoteSessionIdsByTabId. Bounded - // anyway -- every write and every pull merge runs it through the TTL and cap in - // shared/closed-terminal-tab-tombstones.ts, and a resolved host retires its own entries. + // Owner-scoped after all, just not by this path: removing a workspace's session rows prunes these + // by the record's worktreeId. A project moved to another profile leaves them in the source profile + // until its next load, whose deregistered-repo sweep takes them. closedTerminalTabTombstonesByTabId: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' diff --git a/src/main/orca-profiles/profile-project-source-removal.ts b/src/main/orca-profiles/profile-project-source-removal.ts index 37480164ebc..8bf401762f2 100644 --- a/src/main/orca-profiles/profile-project-source-removal.ts +++ b/src/main/orca-profiles/profile-project-source-removal.ts @@ -10,6 +10,7 @@ import { } from './profile-project-session-state' import { isRepoWorktreeId, removeRepoWorktreeRecord } from './profile-project-worktree-identity' +/** Removes repository-owned sessions, lineage, and UI preferences from the source profile after transfer. */ export function removeSourceRepo( state: TransferProfileState, repoId: string @@ -35,6 +36,10 @@ export function removeSourceRepo( ? null : state.ui.lastActiveWorktreeId, filterRepoIds: state.ui.filterRepoIds?.filter((id) => id !== repoId) ?? [], + explorerDisplayRootByWorktree: removeRepoWorktreeRecord( + state.ui.explorerDisplayRootByWorktree, + repoId + ), showDotfilesByWorktree: removeRepoWorktreeRecord(state.ui.showDotfilesByWorktree, repoId) } } diff --git a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts index 485861d748d..d8ad76ea675 100644 --- a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts +++ b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts @@ -7,6 +7,7 @@ import { isWorktreeHostIdentity } from '../../shared/worktree/host-qualified-identity' +/** Includes worktrees referenced only by sessions or UI preferences so profile transfer cannot strand their state. */ export function collectTransferWorktreeIds( state: TransferProfileState, repoId: string @@ -36,6 +37,7 @@ export function collectTransferWorktreeIds( for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { collectSessionWorktreeIds(session, repoId, ids) } + Object.keys(state.ui?.explorerDisplayRootByWorktree ?? {}).forEach(add) Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add) return ids } diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index 3e4d380e4ee..b519b04cad8 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -188,6 +188,31 @@ describe('deregistered repo residue', () => { expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) }) + // A close record names its workspace only in its value, so like a sleeping agent it must seed the + // sweep itself or a removed project's records wait out the TTL. + it("drops a close record that is the orphan repo's only residue, and self-clears", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + closedTerminalTabTombstonesByTabId: { + 'tab-gone': { worktreeId: GONE_WORKTREE, closedAt: Date.now(), reason: 'user' }, + 'tab-live': { worktreeId: LIVE_WORKTREE, closedAt: Date.now(), reason: 'user' } + } + } + }) + + const store = await createStore() + store.flush() + expect( + Object.keys(store.getWorkspaceSession('local').closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['tab-live']) + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + // The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches // them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck. it.each([ diff --git a/src/main/persistence-settings-ui-defaults.test.ts b/src/main/persistence-settings-ui-defaults.test.ts index f947b55b5c8..3e75cc7d021 100644 --- a/src/main/persistence-settings-ui-defaults.test.ts +++ b/src/main/persistence-settings-ui-defaults.test.ts @@ -12,11 +12,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import type { GlobalSettings } from '../shared/global-settings-types' import type { PersistedState } from '../shared/persisted-state-types' -import { - getDefaultPersistedState, - ONBOARDING_FINAL_STEP, - ONBOARDING_FLOW_VERSION -} from '../shared/constants' +import { getDefaultPersistedState } from '../shared/constants' +import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/onboarding-defaults' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 71f313c3a23..10bea531533 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -136,6 +136,25 @@ describe('Store', () => { expect(store.getUI().sidebarWidth).toBe(400) }) + it('updateUI persists sanitized per-worktree explorer roots', async () => { + const store = await createStore() + store.updateUI({ + explorerDisplayRootByWorktree: { + 'repo-1::/repo': '/', + 'repo-2::/repo': 'packages/app', + // @ts-expect-error Deliberately malformed input exercises runtime sanitization. + 'repo-3::/repo': false, + // @ts-expect-error Deliberately malformed prototype key exercises runtime sanitization. + constructor: false + } + }) + + expect(store.getUI().explorerDisplayRootByWorktree).toEqual({ + 'repo-1::/repo': '/', + 'repo-2::/repo': 'packages/app' + }) + }) + it('updateUI persists sanitized per-worktree dotfile visibility', async () => { const store = await createStore() store.updateUI({ diff --git a/src/main/persistence/applying-settings/onboarding-normalization.ts b/src/main/persistence/applying-settings/onboarding-normalization.ts index a778463566e..7c8b17badd1 100644 --- a/src/main/persistence/applying-settings/onboarding-normalization.ts +++ b/src/main/persistence/applying-settings/onboarding-normalization.ts @@ -5,12 +5,12 @@ import type { } from '../../../shared/onboarding-state-types' import type { NotificationSettings } from '../../../shared/notification-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultNotificationSettings } from '../../../shared/notification-settings-defaults' import { - getDefaultNotificationSettings, getDefaultOnboardingState, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION -} from '../../../shared/constants' +} from '../../../shared/onboarding-defaults' export function normalizeNotificationSettings(value: unknown): NotificationSettings { const defaults = getDefaultNotificationSettings() diff --git a/src/main/persistence/applying-settings/ui-state-read.ts b/src/main/persistence/applying-settings/ui-state-read.ts index a640f971f89..ffe122efce0 100644 --- a/src/main/persistence/applying-settings/ui-state-read.ts +++ b/src/main/persistence/applying-settings/ui-state-read.ts @@ -1,3 +1,4 @@ +import { normalizeExplorerDisplayRootByWorktree } from '../../../shared/file-explorer-display-root' import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultUIState, @@ -32,6 +33,7 @@ import { } from './ui-selection-normalization' import { stripMainOwnedTelemetryMarkerFromUI } from './ui-interaction-merge' +/** Returns normalized UI state with the authoritative active view and without the main-owned telemetry marker. */ export function getPersistedUI( state: PersistedState, activeView: PersistedState['ui']['activeView'] @@ -66,6 +68,9 @@ export function getPersistedUI( workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder), manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel), + explorerDisplayRootByWorktree: normalizeExplorerDisplayRootByWorktree( + state.ui?.explorerDisplayRootByWorktree + ), showDotfilesByWorktree: normalizeShowDotfilesByWorktree(state.ui?.showDotfilesByWorktree), featureTipsSeenIds: normalizeFeatureTipIds(state.ui?.featureTipsSeenIds), contextualToursSeenIds: normalizeContextualTourIds(state.ui?.contextualToursSeenIds), diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index 5aad240e554..a89c189f499 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -1,3 +1,4 @@ +import { normalizeExplorerDisplayRootByWorktree } from '../../../shared/file-explorer-display-root' import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultUIState, @@ -53,6 +54,7 @@ export type UIUpdateOperations = { notifyUIChanged: () => void } +/** Applies a sanitized partial update while keeping active-view persistence separate from durable UI fields. */ export function updatePersistedUI( operations: UIUpdateOperations, updates: Partial @@ -169,6 +171,12 @@ export function updatePersistedUI( browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( sanitizedUpdates.browserDefaultZoomLevel ?? operations.state.ui?.browserDefaultZoomLevel ), + explorerDisplayRootByWorktree: + sanitizedUpdates.explorerDisplayRootByWorktree !== undefined + ? normalizeExplorerDisplayRootByWorktree(sanitizedUpdates.explorerDisplayRootByWorktree) + : normalizeExplorerDisplayRootByWorktree( + operations.state.ui?.explorerDisplayRootByWorktree + ), showDotfilesByWorktree: sanitizedUpdates.showDotfilesByWorktree !== undefined ? normalizeShowDotfilesByWorktree(sanitizedUpdates.showDotfilesByWorktree) diff --git a/src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts b/src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts new file mode 100644 index 00000000000..3ec0020ad15 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts @@ -0,0 +1,45 @@ +import { homedir } from 'node:os' +import { expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { normalizeLoadedUiState } from './normalize-loaded-ui-state' + +it.each([undefined, false, null, 1, 'true', {}, true])( + 'persists the root migration unless the raw marker is boolean true (%j)', + (marker) => { + const defaults = getDefaultPersistedState(homedir()) + const onboarding = defaults.onboarding! + const normalizedUI = normalizeLoadedUiState( + defaults, + defaults, + onboarding, + false, + false, + vi.fn() + ) + const parsed: PersistedState = JSON.parse( + JSON.stringify({ + ...defaults, + ui: { ...normalizedUI, _explorerDisplayRootMigrated: marker }, + worktreeMeta: { existing: { sparseDirectories: ['src'] } } + }) + ) + const markNeedsSave = vi.fn() + const ui = normalizeLoadedUiState(parsed, defaults, onboarding, false, false, markNeedsSave) + expect(ui._explorerDisplayRootMigrated).toBe(true) + expect(ui.explorerDisplayRootByWorktree).toEqual(marker === true ? {} : { existing: '/' }) + expect(markNeedsSave).toHaveBeenCalledTimes(marker === true ? 0 : 1) + + markNeedsSave.mockClear() + const reloaded = normalizeLoadedUiState( + { ...parsed, ui }, + defaults, + onboarding, + false, + false, + markNeedsSave + ) + expect(reloaded.explorerDisplayRootByWorktree).toEqual(ui.explorerDisplayRootByWorktree) + expect(markNeedsSave).not.toHaveBeenCalled() + } +) diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts index d26f4e68430..4b208a6fa72 100644 --- a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -1,3 +1,4 @@ +import { migrateExplorerDisplayRoots } from '../../../shared/file-explorer-display-root' import { getWorktreeCardModeProperties, isDefaultedCompactWorktreeCardProperties, @@ -20,6 +21,7 @@ import { import type { PersistedState } from '../../../shared/persisted-state-types' import type { OnboardingState } from '../../../shared/onboarding-state-types' +/** Normalizes legacy UI payloads and marks one-time migrations for saving without replacing explicit user choices. */ export function normalizeLoadedUiState( parsed: PersistedState, defaults: PersistedState, @@ -28,6 +30,14 @@ export function normalizeLoadedUiState( osc52ClipboardNoticePending: boolean, markNeedsSave: () => void ): PersistedState['ui'] { + const explorerDisplayRootByWorktree = migrateExplorerDisplayRoots( + parsed.ui?.explorerDisplayRootByWorktree, + parsed.ui?._explorerDisplayRootMigrated === true, + parsed.worktreeMeta ?? {} + ) + if (parsed.ui?._explorerDisplayRootMigrated !== true) { + markNeedsSave() + } const rawSort = parsed.ui?.sortBy const sort = normalizeSortBy(rawSort) const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' @@ -189,6 +199,8 @@ export function normalizeLoadedUiState( // window exists, and it must survive a crash before the user ever sees the notice. osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, sortBy: migrate ? ('smart' as const) : sort, + _explorerDisplayRootMigrated: true, + explorerDisplayRootByWorktree, showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index b52e748f190..15315518961 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -1,7 +1,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' import type { PersistedState } from '../../../shared/persisted-state-types' -import { getDefaultOnboardingState } from '../../../shared/constants' +import { getDefaultOnboardingState } from '../../../shared/onboarding-defaults' import type { FeatureInteractionId } from '../../../shared/feature-interactions' import { updateSettings as updateSettingsOperation, diff --git a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts index d1c2ed76d7b..cf33a0255dd 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts @@ -1,6 +1,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +/** Mutates UI state to remove selections and preferences whose owning repositories were deregistered. */ export function pruneDeregisteredRepoUiResidue( ui: PersistedState['ui'], orphanRepoIds: ReadonlySet @@ -14,9 +15,11 @@ export function pruneDeregisteredRepoUiResidue( ui.lastActiveWorktreeId = null } ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] - for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { - if (isOrphanWorktree(worktreeId)) { - delete ui.showDotfilesByWorktree?.[worktreeId] + for (const record of [ui.explorerDisplayRootByWorktree, ui.showDotfilesByWorktree]) { + for (const worktreeId of Object.keys(record ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete record?.[worktreeId] + } } } } diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts index c3cb63fb114..245806b8ad0 100644 --- a/src/main/persistence/profile-state/profile-state-database.test.ts +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -330,10 +330,3 @@ describe('profile state database', () => { } }) }) - -describe('profile state database does not reuse orchestration state', () => { - it('uses a profile-local filename', () => { - const directory = createDirectory() - expect(profileStateDatabaseFile(directory)).not.toBe(join(directory, 'orchestration.db')) - }) -}) diff --git a/src/main/persistence/restoring-sessions/session-owner-removal.ts b/src/main/persistence/restoring-sessions/session-owner-removal.ts index 81b18ae2fe6..5fac53546c4 100644 --- a/src/main/persistence/restoring-sessions/session-owner-removal.ts +++ b/src/main/persistence/restoring-sessions/session-owner-removal.ts @@ -8,9 +8,9 @@ import { import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner' import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields' -// Scans the pane-key-keyed maps and the shutdown list once, removing every entry -// owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose tab -// was removed). Kept separate from the O(1) deletes so a batch prune scans each +// Scans the pane-key-keyed maps, the close records and the shutdown list once, removing +// every entry owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose +// tab was removed). Kept separate from the O(1) deletes so a batch prune scans each // collection a single time regardless of how many owners are being removed. export function deleteScannedSessionFieldsForOwners( next: WorkspaceSessionState, @@ -39,6 +39,14 @@ export function deleteScannedSessionFieldsForOwners( } } } + // Why: a close record answers for its workspace; once the workspace's rows go, so does it. + if (next.closedTerminalTabTombstonesByTabId) { + for (const [tabId, record] of Object.entries(next.closedTerminalTabTombstonesByTabId)) { + if (isRemovedOwner(record.worktreeId)) { + delete next.closedTerminalTabTombstonesByTabId[tabId] + } + } + } next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( (worktreeId) => !isRemovedOwner(worktreeId) ) diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts index cf97adc11ae..e26d43c6cc7 100644 --- a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -142,6 +142,9 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set { for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { addWorktreeId(tombstone.worktreeId) } + for (const record of Object.values(session.closedTerminalTabTombstonesByTabId ?? {})) { + addWorktreeId(record.worktreeId) + } } return orphanRepoIds } diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index a41f7c6319d..2382cb6a371 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -74,6 +74,11 @@ export function pruneWorktreeStateForRepo( (tombstone) => tombstone.worktreeId ) ) + collectPrefixedKeys( + Object.values(session?.closedTerminalTabTombstonesByTabId ?? {}).map( + (record) => record.worktreeId + ) + ) } collectPrefixedKeys(Object.keys(state.worktreeMeta)) collectScannedRecordOwners(state.workspaceSession) diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index 31b318b3143..2bc7fa95e6f 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -330,6 +330,9 @@ export function migrateWorktreeIdentity( )) { changed = moveKey(selectionsByWorktree) || changed } + if (state.ui?.explorerDisplayRootByWorktree) { + changed = moveKey(state.ui.explorerDisplayRootByWorktree) || changed + } const showDotfiles = state.ui?.showDotfilesByWorktree if (showDotfiles) { changed = moveKey(showDotfiles) || changed diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 6b66d7f337f..608c331c017 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -213,22 +213,8 @@ describe('PiTitlebarExtensionService', () => { join(piHome, 'extensions', 'orca-agent-status.ts'), 'utf-8' ) - const titlebarExtensionSource = readFileSync( - join(piHome, 'extensions', 'orca-titlebar-spinner.ts'), - 'utf-8' - ) - const prefillExtensionSource = readFileSync( - join(piHome, 'extensions', 'orca-prefill.ts'), - 'utf-8' - ) expect(statusExtensionSource).toContain('@orca-managed-pi-extension') expect(statusExtensionSource).toContain('/hook/pi') - expect(statusExtensionSource).toContain('process.title') - expect(statusExtensionSource).toContain("return '/hook/omp'") - expect(titlebarExtensionSource).toContain('@orca-managed-pi-extension') - expect(titlebarExtensionSource).toContain('process.env.ORCA_PANE_KEY') - expect(prefillExtensionSource).toContain('@orca-managed-pi-extension') - expect(prefillExtensionSource).toContain('process.env.ORCA_PANE_KEY') expectPiHomeIntact() }) @@ -252,7 +238,6 @@ describe('PiTitlebarExtensionService', () => { ]) const source = readFileSync(join(piHome, 'extensions', 'orca-agent-status.ts'), 'utf-8') expect(source).toContain('/hook/prime-agent') - expect(source).not.toContain("return '/hook/omp'") expect(existsSync(join(piHome, 'extensions', 'orca-titlebar-spinner.ts'))).toBe(false) expect(existsSync(join(piHome, 'extensions', 'orca-prefill.ts'))).toBe(false) expectPiHomeIntact() @@ -277,7 +262,6 @@ describe('PiTitlebarExtensionService', () => { const env = svc.buildPtyEnv('pty-omp-sqlite', piHome, 'omp') const sourcePath = join(piHome, 'agent.db') - const content = 'agent.db credentials' expect(env.PI_CODING_AGENT_DIR).toBeUndefined() expect(readFileSync(env.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') @@ -287,9 +271,6 @@ describe('PiTitlebarExtensionService', () => { expect(existsSync(sourcePath)).toBe(false) expect(existsSync(join(userDataDir, 'omp-agent-overlays'))).toBe(false) expect(existsSync(join(piHome, 'history.db'))).toBe(false) - writeFileSync(sourcePath, content) - - expect(readFileSync(sourcePath, 'utf-8')).toBe(content) }) it('migrates missing OMP state from the old source overlay without overwriting source files', () => { diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts index 37e8451a4da..d79fe80fbbd 100644 --- a/src/main/ports/port-scan-command-client.test.ts +++ b/src/main/ports/port-scan-command-client.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join, sep } from 'node:path' import { Worker } from 'node:worker_threads' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -244,17 +243,6 @@ describe('resolveWorkerEntryPath', () => { expect(resolved).toBe(join(moduleDir, WORKER_ENTRY_FILENAME)) expect(resolved).not.toContain('app.asar') }) - - // A rename in the build config would leave both branches pointing at a file - // that is never emitted, and only the packaged one fails silently. - it('names the entry the main build actually emits', () => { - const config = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), - 'utf8' - ) - - expect(config).toContain("'port-scan-command-worker-entry': resolve(") - }) }) const REAL_WORKER_BLOCK_MS = 800 diff --git a/src/main/ports/port-scan-command-import-boundary.test.ts b/src/main/ports/port-scan-command-import-boundary.test.ts deleted file mode 100644 index bd9b9d05fb8..00000000000 --- a/src/main/ports/port-scan-command-import-boundary.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why (#11161): libuv runs process creation inline on the calling event loop, -// so the port scan only stays off CrBrowserMain while these main-thread modules -// spawn nothing themselves. Spawning belongs to port-scan-command-execution.ts, -// which only the worker entry imports. -const MAIN_THREAD_MODULES = ['local-workspace-port-scanner.ts', 'port-scan-command-client.ts'] - -describe('port scan main-thread spawn boundary', () => { - it.each(MAIN_THREAD_MODULES)('keeps %s free of child_process', (fileName) => { - const source = readFileSync(join(import.meta.dirname, fileName), 'utf8') - - expect(source).not.toMatch(/from\s+['"](node:)?child_process['"]/) - expect(source).not.toMatch(/require\(\s*['"](node:)?child_process['"]\s*\)/) - }) -}) diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index f776521d498..0dd39fa2a67 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,5 +1,5 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' -import { getPowerShellCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' +import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' /** diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts index 857017b6671..f934a7e7413 100644 --- a/src/main/providers/local-pty-child-process-verdict.test.ts +++ b/src/main/providers/local-pty-child-process-verdict.test.ts @@ -149,13 +149,6 @@ describeOnPosix('inspectLocalPtyChildProcesses on a retired master', () => { // Not `no-children`: the close guard reads that as "nothing is running here" and kills the pane. expect(await inspectLocalPtyChildProcesses('pty-retired')).toBe('unverifiable') }, 15000) - - it('preserves uncertainty conservatively in the boolean adapter', async () => { - await registerRetiredPane('pty-retired') - - // The adapter exists for `IPtyProvider.hasChildProcesses`, which has no third slot. - await expect(hasLocalPtyChildProcesses('pty-retired')).resolves.toBe(true) - }, 15000) }) describe('inspectPtyProviderProcess child-process evidence', () => { diff --git a/src/main/providers/local-pty-provider-io-events.test.ts b/src/main/providers/local-pty-provider-io-events.test.ts index 488e203e7e1..abc1c5dc575 100644 --- a/src/main/providers/local-pty-provider-io-events.test.ts +++ b/src/main/providers/local-pty-provider-io-events.test.ts @@ -265,7 +265,7 @@ describe('LocalPtyProvider', () => { ]) }) - it('consumes a native Windows OSC color query before renderer delivery', async () => { + it('answers a native Windows OSC color query from pushed colours before renderer delivery', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const dataHandler = vi.fn() provider.onData(dataHandler) @@ -277,6 +277,8 @@ describe('LocalPtyProvider', () => { const onDataCb = mockProc.onData.mock.calls[0][0] const query = '\x1b]10;?\x07' + onDataCb(query) + provider.setColorQueryReplyColors({ foreground: '#123456', background: '#000000' }) onDataCb(query) expect(dataHandler).toHaveBeenCalledWith({ @@ -286,7 +288,10 @@ describe('LocalPtyProvider', () => { seq: query.length, transformed: true }) - expect(mockProc.write).not.toHaveBeenCalled() + expect(mockProc.write.mock.calls).toEqual([ + ['\x1b]10;rgb:ffff/ffff/ffff\x1b\\'], + ['\x1b]10;rgb:1212/3434/5656\x1b\\'] + ]) }) it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 728e8a1273a..9c2f3c80c9c 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -14,6 +14,11 @@ import { } from './local-pty-foreground-inspection' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtyProcessInspection } from './pty-process-inspection' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' +import { + _resetPtyOwnerHostColorsForTest, + setPtyOwnerHostColors +} from '../../shared/pty-owner-color-query-colors' import { advanceLoadGeneration, clearPtyState, @@ -123,6 +128,9 @@ export class LocalPtyProvider implements IPtyProvider { closeStartupQueryAuthority(id: string): number { return closeLocalPtyStartupQueryAuthority(id) } + setColorQueryReplyColors(colors: TerminalOscColorQueryReplyColors): void { + setPtyOwnerHostColors(colors) + } acknowledgeDataEvent(_id: string, _charCount: number): void { /* no flow control for local */ } @@ -224,4 +232,5 @@ export function _resetLocalPtyProviderStateForTest(): void { clearPtyState(id) } resetLoadGeneration() + _resetPtyOwnerHostColorsForTest() } diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 877fdda3bcc..0bbcd0712ea 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -7,7 +7,7 @@ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' -import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { getBashStartupCommandPromptBlock } from '../pty/posix-shell-startup-command' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index 47fb521afcb..528dd6ee149 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -27,8 +27,7 @@ export function getLocalZshWrapperSpec(): ZshStartupHookSpec { managedWslCli: true, agentTeamsPath: true, remoteCliBinDir: false, - codexHome: true, - codexLaunchPreflight: true + codexHome: true } } } diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 61a04fdf024..5a0388aff9f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -10,7 +10,7 @@ import { getPowerShellOsc133Bootstrap, isPowerShellExecutableName } from '../powershell-osc133-bootstrap' -import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' +import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' import { getFishShellReadyInitCommand } from '../shell-templates' import { diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index ad29faf376e..585c9eb9d38 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -608,16 +608,6 @@ describe('probeMacosLoginSessionAlive', () => { expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') }) - it('does not overwrite the cached verdict on an inconclusive probe', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - runProcessMock.mockReturnValue(TIMED_OUT_RESULT) - ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) - const outcome = await probeMacosLoginSessionAlive() - expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - it('does not trust a pipe rejection when its PTY confirmation is inconclusive', async () => { setPlatform('darwin') await prepareMacosTccLoginShell() diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index d2b38bd4b2f..e10fd44df46 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,5 +1,7 @@ import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentWorkspaceTrustSpawnRequest } from '../../shared/agent-workspace-trust-spawn-request' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' @@ -77,6 +79,8 @@ export type PtySpawnOptions = { isNewSession?: boolean /** Host setting forwarded additively to the process owner; old owners ignore it. */ historyIsolationEnabled?: boolean + /** SSH only: workspace the relay pre-trusts for `launchAgent` before spawning; old relays ignore it. */ + agentWorkspaceTrust?: AgentWorkspaceTrustSpawnRequest /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean /** Exact persisted owner expected by an attach-only routing decision. */ @@ -226,8 +230,10 @@ export type IPtyProvider = { clearBuffer(id: string): Promise /** Grounds the host's own terminal models (Reset Terminal); renderers ground themselves. */ resetInputModes(id: string): Promise - /** Ordered handoff from startup source authority to the live/hidden view authority. */ + /** Ends the startup Kitty-query window; OSC 10/11 authority stays with the owner for life. */ closeStartupQueryAuthority?: (id: string) => Promise | number + /** Host-wide viewer colours the PTY owner answers OSC 10/11 from. */ + setColorQueryReplyColors?: (colors: TerminalOscColorQueryReplyColors) => void acknowledgeDataEvent(id: string, charCount: number): void hasChildProcesses(id: string): Promise getForegroundProcess(id: string): Promise diff --git a/src/main/providers/settled-pty-writer-census.test.ts b/src/main/providers/settled-pty-writer-census.test.ts deleted file mode 100644 index 08dd9989400..00000000000 --- a/src/main/providers/settled-pty-writer-census.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { execFileSync } from 'node:child_process' -import { describe, expect, it, vi } from 'vitest' -import { LocalPtyProvider } from './local-pty-provider' -import { SshPtyProvider } from './ssh-pty-provider' -import { createMockMux } from './ssh-pty-provider-mock-multiplexer' -import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' -import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }, - BrowserWindow: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - webContents: { fromId: vi.fn(() => null) } -})) - -const REPO_ROOT = join(__dirname, '..', '..', '..') - -/** - * Requiring the method is satisfiable by a lie: the degraded daemon router used to answer it - * with `provider.write(...) !== false`, reproducing the fire-and-forget bug through the fix. - * The census pins the producers and reads their bodies, so a new provider or a revived - * fabricated handoff fails here rather than silently clearing a mailbox reservation. - */ -const SETTLED_PTY_WRITER_FILES = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', - 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', - 'src/main/daemon/daemon-pty-adapter.ts' -] - -/** Where the provider-side settlement is actually decided; the adapter inherits its own. */ -const SETTLED_WRITER_DECLARATIONS = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', - 'src/main/providers/ssh-pty-provider-rpc-operations.ts', - 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', - 'src/main/daemon/daemon-pty-session-input.ts' -] - -function declaredProviderFiles(): string[] { - const output = execFileSync('git', ['grep', '-l', '--', 'implements IPtyProvider', 'src/main'], { - cwd: REPO_ROOT, - encoding: 'utf8' - }) - // Tests may name the clause while pinning it; only production declarations count. - return output - .split('\n') - .filter((file) => file && !file.endsWith('.test.ts')) - .sort() -} - -function settledWriterBody(file: string): string { - const source = readFileSync(join(REPO_ROOT, file), 'utf8') - const start = source.indexOf('writeWithSettlement') - expect(start, `${file} declares no settled writer`).toBeGreaterThan(-1) - const end = source.indexOf('\n }', start) - return source.slice(start, end === -1 ? source.length : end) -} - -describe('settled PTY writer census', () => { - it('covers every production provider class that declares IPtyProvider', () => { - expect(declaredProviderFiles()).toEqual([...SETTLED_PTY_WRITER_FILES].sort()) - }) - - it('exposes a settled writer on every production provider instance', () => { - const daemonClient = { isConnected: () => false, onEvent: vi.fn(() => vi.fn()) } - const adapter = new DaemonPtyAdapter(daemonClient as never) - const instances = [ - new LocalPtyProvider({} as never), - new SshPtyProvider('conn-census', createMockMux() as never), - new DaemonPtyRouter({ current: adapter, legacy: [] }), - new DegradedDaemonPtyProvider({ - current: adapter, - legacy: [], - fallback: new LocalPtyProvider({} as never) - }), - adapter - ] - for (const provider of instances) { - expect(typeof provider.writeWithSettlement, provider.constructor.name).toBe('function') - } - }) - - it('never synthesizes a settlement from the fire-and-forget write', () => { - for (const file of SETTLED_WRITER_DECLARATIONS) { - expect(settledWriterBody(file), file).not.toMatch(/\.write\(/) - } - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index a20dfb98d9b..33b063ffa0c 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -59,10 +59,6 @@ describe('SshFilesystemProvider', () => { provider = new SshFilesystemProvider('conn-1', mux as never) }) - it('returns the connectionId', () => { - expect(provider.getConnectionId()).toBe('conn-1') - }) - describe('readDir', () => { it('sends fs.readDir request', async () => { const entries = [ @@ -527,18 +523,6 @@ describe('SshFilesystemProvider', () => { }) describe('watch', () => { - it('sends fs.watch request and returns unsubscribe', async () => { - const callback = vi.fn() - const unsub = await provider.watch('/home/user/project', callback) - - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - expect(typeof unsub).toBe('function') - }) - it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { mux.request.mockResolvedValue(undefined) const controller = new AbortController() diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts deleted file mode 100644 index 3e6ebf76d74..00000000000 --- a/src/main/providers/ssh-git-provider-api.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { IGitProvider } from './types' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux } from './ssh-git-provider-test-harness' - -describe('SshGitProvider public API parity', () => { - it('retains every historical provider operation', () => { - const provider: IGitProvider & SshGitProvider = new SshGitProvider( - 'conn-1', - createMockMux() as never - ) - const methods = [ - 'getConnectionId', - 'getHostPlatform', - 'getStatus', - 'getSubmoduleStatus', - 'checkIgnoredPaths', - 'getHistory', - 'commit', - 'getStagedCommitContext', - 'executeCommitMessagePlan', - 'execNonInteractive', - 'cancelNonInteractiveExec', - 'cancelGenerateCommitMessage', - 'getDiff', - 'stageFile', - 'unstageFile', - 'bulkStageFiles', - 'bulkUnstageFiles', - 'discardChanges', - 'bulkDiscardChanges', - 'detectConflictOperation', - 'abortMerge', - 'abortRebase', - 'checkoutBranch', - 'listLocalBranches', - 'getBranchCompare', - 'getCommitCompare', - 'getUpstreamStatus', - 'pushBranch', - 'pullBranch', - 'fastForwardBranch', - 'rebaseFromBase', - 'fetchRemote', - 'syncForkDefaultBranch', - 'fetchRemoteTrackingRef', - 'fetchGitLabMergeRequestHead', - 'fetchGitHubPullRequestHead', - 'getBranchDiff', - 'getCommitDiff', - 'listWorktrees', - 'addWorktree', - 'removeWorktree', - 'worktreeIsClean', - 'refreshLocalBaseRefForWorktreeCreate', - 'renameCurrentBranch', - 'markRemoteOrcaCreated', - 'forceDeletePreservedBranch', - 'exec', - 'clone', - 'isGitRepoAsync', - 'isGitRepo', - 'getRemoteFileUrl', - 'getRemoteCommitUrl' - ] as const - - expect(methods).toHaveLength(52) - for (const method of methods) { - expect(provider[method], method).toBeTypeOf('function') - } - }) -}) diff --git a/src/main/providers/ssh-git-provider-status-lease.test.ts b/src/main/providers/ssh-git-provider-status-lease.test.ts index f7a8a09018b..9f436ba1a1a 100644 --- a/src/main/providers/ssh-git-provider-status-lease.test.ts +++ b/src/main/providers/ssh-git-provider-status-lease.test.ts @@ -29,122 +29,6 @@ describe('SshGitProvider status read leases', () => { provider = new SshGitProvider('conn-1', mux as never) }) - it('shares one status RPC across distinct caller signals', async () => { - const pending = deferredPromise<{ - entries: never[] - conflictOperation: 'unknown' - }>() - mux.request.mockReturnValue(pending.promise) - const controllers = Array.from({ length: 10 }, () => new AbortController()) - - const reads = controllers.map((controller) => - provider.getStatus('/home/user/repo', { signal: controller.signal }) - ) - await waitForRequestCount(mux.request, 1) - - expect(mux.request).toHaveBeenCalledTimes(1) - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - await expect(Promise.all(reads)).resolves.toHaveLength(10) - }) - - it('isolates first and later caller cancellation while a status lease remains', async () => { - const pending = deferredPromise<{ - entries: never[] - conflictOperation: 'unknown' - }>() - mux.request.mockReturnValue(pending.promise) - const firstController = new AbortController() - const secondController = new AbortController() - const thirdController = new AbortController() - const firstError = new Error('first caller cancelled') - const thirdError = new Error('third caller cancelled') - - const first = provider.getStatus('/home/user/repo', { signal: firstController.signal }) - const second = provider.getStatus('/home/user/repo', { signal: secondController.signal }) - const third = provider.getStatus('/home/user/repo', { signal: thirdController.signal }) - await waitForRequestCount(mux.request, 1) - const sharedSignal = mux.request.mock.calls[0][2].signal as AbortSignal - - firstController.abort(firstError) - thirdController.abort(thirdError) - await expect(first).rejects.toBe(firstError) - await expect(third).rejects.toBe(thirdError) - expect(sharedSignal.aborted).toBe(false) - - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - await expect(second).resolves.toEqual({ entries: [], conflictOperation: 'unknown' }) - }) - - it('cancels the underlying status RPC after its last live lease aborts', async () => { - const firstPending = deferredPromise<{ - entries: never[] - conflictOperation: 'unknown' - }>() - mux.request.mockReturnValueOnce(firstPending.promise) - const firstController = new AbortController() - const secondController = new AbortController() - const firstError = new Error('first caller cancelled') - const secondError = new Error('second caller cancelled') - const first = provider.getStatus('/home/user/repo', { signal: firstController.signal }) - const second = provider.getStatus('/home/user/repo', { signal: secondController.signal }) - await waitForRequestCount(mux.request, 1) - const sharedSignal = mux.request.mock.calls[0][2].signal as AbortSignal - sharedSignal.addEventListener('abort', () => firstPending.reject(sharedSignal.reason), { - once: true - }) - - firstController.abort(firstError) - await expect(first).rejects.toBe(firstError) - expect(sharedSignal.aborted).toBe(false) - secondController.abort(secondError) - await expect(second).rejects.toBe(secondError) - expect(sharedSignal.aborted).toBe(true) - - mux.request.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' }) - await expect(provider.getStatus('/home/user/repo')).resolves.toMatchObject({ entries: [] }) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('rejects pre-aborted status callers without starting or joining an RPC', async () => { - const controller = new AbortController() - const abortError = new Error('already cancelled') - controller.abort(abortError) - - await expect(provider.getStatus('/home/user/repo', { signal: controller.signal })).rejects.toBe( - abortError - ) - expect(mux.request).not.toHaveBeenCalled() - - const pending = deferredPromise<{ - entries: never[] - conflictOperation: 'unknown' - }>() - mux.request.mockReturnValueOnce(pending.promise) - const active = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 1) - await expect(provider.getStatus('/home/user/repo', { signal: controller.signal })).rejects.toBe( - abortError - ) - expect(mux.request).toHaveBeenCalledTimes(1) - - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - await active - }) - - it('starts fresh status RPCs after result and error settlement', async () => { - const status = { entries: [], conflictOperation: 'unknown' as const } - mux.request.mockResolvedValueOnce(status) - - await expect(provider.getStatus('/home/user/repo')).resolves.toBe(status) - const failure = new Error('relay status failed') - mux.request.mockRejectedValueOnce(failure) - await expect(provider.getStatus('/home/user/repo')).rejects.toBe(failure) - mux.request.mockResolvedValueOnce(status) - await expect(provider.getStatus('/home/user/repo')).resolves.toBe(status) - - expect(mux.request).toHaveBeenCalledTimes(3) - }) - it('isolates status reads by worktree and output-affecting options', async () => { const pendingRequests = Array.from({ length: 8 }, () => deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() diff --git a/src/main/providers/ssh-git-provider-status.test.ts b/src/main/providers/ssh-git-provider-status.test.ts index 1eb727eacfb..bb5d8adde2e 100644 --- a/src/main/providers/ssh-git-provider-status.test.ts +++ b/src/main/providers/ssh-git-provider-status.test.ts @@ -11,10 +11,6 @@ describe('SshGitProvider', () => { provider = new SshGitProvider('conn-1', mux as never) }) - it('returns the connectionId', () => { - expect(provider.getConnectionId()).toBe('conn-1') - }) - it('getStatus sends git.status request', async () => { const statusResult = { entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }], diff --git a/src/main/providers/ssh-git-provider-upstream-lease.test.ts b/src/main/providers/ssh-git-provider-upstream-lease.test.ts index d13c2d705bc..5717be064c4 100644 --- a/src/main/providers/ssh-git-provider-upstream-lease.test.ts +++ b/src/main/providers/ssh-git-provider-upstream-lease.test.ts @@ -29,23 +29,6 @@ describe('SshGitProvider upstream status read leases', () => { provider = new SshGitProvider('conn-1', mux as never) }) - it('shares one upstream-status RPC across ten identical callers', async () => { - const pending = deferredPromise<{ - hasUpstream: true - upstreamName: string - ahead: number - behind: number - }>() - mux.request.mockReturnValue(pending.promise) - - const reads = Array.from({ length: 10 }, () => provider.getUpstreamStatus('/home/user/repo')) - await waitForRequestCount(mux.request, 1) - - expect(mux.request).toHaveBeenCalledTimes(1) - pending.resolve({ hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 }) - await expect(Promise.all(reads)).resolves.toHaveLength(10) - }) - it('isolates upstream-status RPCs by worktree and every target field', async () => { mux.request.mockResolvedValue({ hasUpstream: true, @@ -89,32 +72,6 @@ describe('SshGitProvider upstream status read leases', () => { expect(mux.request).toHaveBeenCalledTimes(2) }) - it('runs a fresh upstream-status RPC after result and error settlement', async () => { - const failure = new Error('upstream RPC failed') - mux.request - .mockResolvedValueOnce({ - hasUpstream: true, - upstreamName: 'origin/main', - ahead: 0, - behind: 0 - }) - .mockRejectedValueOnce(failure) - .mockResolvedValueOnce({ - hasUpstream: false, - ahead: 0, - behind: 0 - }) - - await expect(provider.getUpstreamStatus('/home/user/repo')).resolves.toMatchObject({ - hasUpstream: true - }) - await expect(provider.getUpstreamStatus('/home/user/repo')).rejects.toBe(failure) - await expect(provider.getUpstreamStatus('/home/user/repo')).resolves.toMatchObject({ - hasUpstream: false - }) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - it('fences upstream-status reads before, during, and after an SSH mutation', async () => { const upstreamRequests = Array.from({ length: 3 }, () => deferredPromise<{ hasUpstream: false; ahead: 0; behind: 0 }>() diff --git a/src/main/providers/ssh-pty-provider-rpc-operations.ts b/src/main/providers/ssh-pty-provider-rpc-operations.ts index 361bdc15715..46b32b8df99 100644 --- a/src/main/providers/ssh-pty-provider-rpc-operations.ts +++ b/src/main/providers/ssh-pty-provider-rpc-operations.ts @@ -2,6 +2,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { PtyProcessInspection } from './pty-process-inspection' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' type SshPtyProviderRpcContext = { mux: SshChannelMultiplexer @@ -43,6 +44,10 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP })) as { appliedSeq?: number } return result.appliedSeq ?? 0 }, + // A notification, because an older relay drops unknown ones instead of failing. + setColorQueryReplyColors: (colors: TerminalOscColorQueryReplyColors): void => { + mux.notify('pty.setColorQueryReplyColors', { colors }) + }, acknowledgeDataEvent: (id: string, charCount: number): void => { mux.notify('pty.ackData', { id: toRelayPtyId(id), charCount }) }, diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 56b5328e16e..8530691b159 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -17,10 +17,6 @@ describe('SshPtyProvider', () => { provider = new SshPtyProvider('conn-1', mux as never) }) - it('returns the connectionId', () => { - expect(provider.getConnectionId()).toBe('conn-1') - }) - it('reports that SSH panes cannot restore from authoritative provider snapshots', () => { expect(provider.canProvideAuthoritativeBufferSnapshot(scopedPty1)).toBe(false) }) @@ -236,6 +232,13 @@ describe('SshPtyProvider', () => { expectRequest(mux.request, 'pty.clearBuffer', { id: 'pty-1' }) }) + it('pushes host colours as a notification an older relay can ignore', () => { + const colors = { foreground: '#ffffff', background: '#282c34' } + provider.setColorQueryReplyColors(colors) + expect(mux.notify).toHaveBeenCalledWith('pty.setColorQueryReplyColors', { colors }) + expect(mux.request).not.toHaveBeenCalledWith('pty.setColorQueryReplyColors', expect.anything()) + }) + it('acknowledgeDataEvent sends pty.ackData notification', () => { provider.acknowledgeDataEvent(scopedPty1, 1024) expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 1024 }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 854e076cec5..59088247e18 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,6 +1,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' import type { @@ -58,6 +59,8 @@ export class SshPtyProvider implements IPtyProvider { resetInputModes = (id: string): Promise => this.rpcOperations.resetInputModes(id) closeStartupQueryAuthority = (id: string): Promise => this.rpcOperations.closeStartupQueryAuthority(id) + setColorQueryReplyColors = (colors: TerminalOscColorQueryReplyColors): void => + this.rpcOperations.setColorQueryReplyColors(colors) acknowledgeDataEvent = (id: string, charCount: number): void => this.rpcOperations.acknowledgeDataEvent(id, charCount) hasChildProcesses = (id: string): Promise => this.rpcOperations.hasChildProcesses(id) diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 9f493d9e778..170a50bb7bd 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -26,6 +26,7 @@ export function buildSshPtySpawnRequest(args: { ...(options.historyIsolationEnabled !== undefined ? { historyIsolationEnabled: options.historyIsolationEnabled } : {}), + ...(options.agentWorkspaceTrust ? { agentWorkspaceTrust: options.agentWorkspaceTrust } : {}), ...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}), ...(options.terminalWindowsWslDistro !== undefined ? { terminalWindowsWslDistro: options.terminalWindowsWslDistro } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index c71b9b06722..38352a1b6d4 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -308,6 +308,31 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) + it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { + const plain = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice' + ) + const launched = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + "codex 'fix the bug'" + ) + + expect(plain.shellArgs).toEqual([ + '-c', + 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' + ]) + // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. + expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( + 'set -- --no-daemon "$@"' + ) + expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('quotes a spaced preflight path through each shell environment', () => { const cmd = resolveWindowsShellLaunchArgs( 'cmd.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 70fd22a06ad..c5ceade00a4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,8 +29,9 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string { - if (!codexLaunchPreflightCommand) { +// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. +function getGitBashLaunchCommand(useWrapper: boolean): string { + if (!useWrapper) { return GIT_BASH_UTF8_LOGIN_COMMAND } @@ -216,7 +217,10 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['-c', getGitBashLaunchCommand(codexLaunchPreflightCommand)], + shellArgs: [ + '-c', + getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) + ], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/pty/codex-launch-shell-wrapping.test.ts b/src/main/pty/codex-launch-shell-wrapping.test.ts new file mode 100644 index 00000000000..02d6c223a2f --- /dev/null +++ b/src/main/pty/codex-launch-shell-wrapping.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { selectShellStartupFeatures } from '../shell-startup-features' + +// Why: the codex --no-daemon wrapper only reaches shells Orca wraps. Orca's own +// Codex launches carry a startup command, so each launch shell must be wrapped. +const COMMAND = "codex 'fix the bug'" +const NO_DAEMON = 'set -- --no-daemon "$@"' +const FISH_NO_DAEMON = 'set argv --no-daemon $argv' + +function codexLaunchFeatures(shellPath: string) { + // Why an empty env: a system-default Codex home, with hooks off, carries no overlay key. + const waitsForShellReady = shouldUseShellReadyStartupDelivery({ command: COMMAND, shellPath }) + return selectShellStartupFeatures({ + shellPath, + env: {}, + hasStartupCommand: true, + waitsForShellReady, + emitsStartupIdentity: waitsForShellReady + }) +} + +function wrapperText(config: { args: string[] | null; env: Record }): string { + if (config.env.ZDOTDIR) { + return readFileSync(join(config.env.ZDOTDIR, '.zshenv'), 'utf8') + } + const rcfile = config.args?.[config.args.indexOf('--rcfile') + 1] + return rcfile && config.args?.includes('--rcfile') + ? readFileSync(rcfile, 'utf8') + : (config.args ?? []).join('\n') +} + +describe.skipIf(process.platform === 'win32')('Orca Codex launch shells carry the wrapper', () => { + let userData: string + const original = process.env.ORCA_USER_DATA_PATH + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-codex-launch-wrap-')) + process.env.ORCA_USER_DATA_PATH = userData + vi.resetModules() + }) + + afterEach(() => { + if (original === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = original + } + rmSync(userData, { recursive: true, force: true }) + }) + + it.each([ + ['/bin/bash', NO_DAEMON], + ['/bin/zsh', NO_DAEMON], + ['/usr/bin/fish', FISH_NO_DAEMON] + ])('daemon transport wraps %s', async (shell, marker) => { + const { getShellLaunchConfig } = await import('../daemon/shell-ready') + + expect( + wrapperText( + getShellLaunchConfig(shell, codexLaunchFeatures(shell), { hasStartupCommand: true }) + ) + ).toContain(marker) + }) + + it.each([ + ['/bin/bash', NO_DAEMON], + ['/bin/zsh', NO_DAEMON], + ['/usr/bin/fish', FISH_NO_DAEMON] + ])('local transport wraps %s', async (shell, marker) => { + const { getShellLaunchConfig } = await import('../providers/local-pty-shell-ready') + + expect(wrapperText(getShellLaunchConfig(shell, codexLaunchFeatures(shell), COMMAND))).toContain( + marker + ) + }) +}) diff --git a/src/main/pty/codex-no-daemon-binary-contract.test.ts b/src/main/pty/codex-no-daemon-binary-contract.test.ts new file mode 100644 index 00000000000..1e0116f2688 --- /dev/null +++ b/src/main/pty/codex-no-daemon-binary-contract.test.ts @@ -0,0 +1,75 @@ +import { execFile } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { CODEX_SHARED_SERVER_ARGS } from '../../shared/codex-shell-function' + +// Why: Orca's codex shell wrapper puts --no-daemon first for every subcommand but +// agents/queue (src/shared/codex-shell-function.ts). Its tests use a fake codex, so +// only the real binary can catch a renamed flag or a new subcommand rejecting it. + +const execFileAsync = promisify(execFile) +const binary = process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY +const expectedVersion = process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION +const WRAPPER_SKIPPED_SUBCOMMANDS: ReadonlySet = new Set(CODEX_SHARED_SERVER_ARGS) +const TIMEOUT_MS = 30_000 + +describe.runIf(process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED === '1' && !binary)( + 'codex --no-daemon contract prerequisites', + () => { + it('was given a Codex binary to run against', () => { + expect.fail('ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED=1 but no binary was given') + }) + } +) + +describe.runIf(binary)('codex --no-daemon binary contract', { timeout: 120_000 }, () => { + let home: string + let help: string + + beforeAll(async () => { + // Why a disposable home: never read or start anything under the user's ~/.codex. + home = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-contract-')) + const version = await run(['--version']) + expect(version.stdout.trim()).toBe(`codex-cli ${expectedVersion}`) + help = (await run(['--help'])).stdout + }) + + afterAll(() => { + rmSync(home, { recursive: true, force: true }) + }) + + function run(args: string[]): Promise<{ stdout: string; stderr: string }> { + return execFileAsync(binary!, args, { + timeout: TIMEOUT_MS, + env: { ...process.env, CODEX_HOME: home } + }) + } + + function listedSubcommands(): string[] { + const section = help.split(/^Commands:\n/m)[1]?.split(/\n\s*\n/)[0] ?? '' + return [...section.matchAll(/^ {2}([a-z][a-z0-9-]*)\s/gm)].map((match) => match[1]) + } + + it('lists --no-daemon in --help, which is what the wrapper probes', () => { + expect(help).toContain('--no-daemon') + }) + + it('accepts --no-daemon first for every listed subcommand the wrapper does not skip', async () => { + const subcommands = listedSubcommands() + // Why a floor: a help layout change must fail here, not shrink the check to nothing. + expect(subcommands.length).toBeGreaterThan(20) + expect(subcommands).toEqual(expect.arrayContaining(['exec', 'resume', 'agents', 'queue'])) + const rejected: string[] = [] + for (const subcommand of subcommands.filter((name) => !WRAPPER_SKIPPED_SUBCOMMANDS.has(name))) { + // Why bare `help`: clap's help subcommand treats `--help` as a command name. + const args = subcommand === 'help' ? ['help'] : [subcommand, '--help'] + await run(['--no-daemon', ...args]).catch((error: { stderr?: string }) => + rejected.push(`${subcommand}: ${error.stderr?.trim() ?? String(error)}`) + ) + } + expect(rejected).toEqual([]) + }) +}) diff --git a/src/main/pty/codex-no-daemon-launch-command.test.ts b/src/main/pty/codex-no-daemon-launch-command.test.ts new file mode 100644 index 00000000000..985f9a2131c --- /dev/null +++ b/src/main/pty/codex-no-daemon-launch-command.test.ts @@ -0,0 +1,102 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { planCodexNoDaemonLaunch, type LocalCodexLaunch } from './codex-no-daemon-launch-command' + +const HELP_WITH_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-daemon Run in-process\n' +const HELP_WITHOUT_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-alt-screen\n' +const hostPlatform = process.platform + +describe.skipIf(hostPlatform === 'win32')('planCodexNoDaemonLaunch', () => { + let dir: string + let codex: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-launch-')) + codex = writeCodex('codex', HELP_WITH_FLAG) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) + }) + + function writeCodex(name: string, help: string): string { + const path = join(dir, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${help}'\n`) + chmodSync(path, 0o755) + return path + } + + function plan(command: string, overrides: Partial = {}) { + return planCodexNoDaemonLaunch({ + command, + executesOnThisHost: true, + shellOverride: undefined, + env: {}, + cwd: dir, + ...overrides + }) + } + + it('adds --no-daemon once, right after a path-named codex', async () => { + await expect(plan(`${codex} --yolo 'fix the bug'`)).resolves.toBe( + `${codex} --no-daemon --yolo 'fix the bug'` + ) + }) + + it.each([ + ['agents'], + ['queue --thread T'], + ['--no-daemon'], + ['resume --no-daemon'], + ['--remote unix://'], + ['--remote=ws://h:1'] + ])('leaves `codex %s` alone: it needs the shared server or has the flag', (args) => { + expect(plan(`${codex} ${args}`)).toBeNull() + }) + + it('leaves SSH and WSL launches to the codex function on that host', () => { + expect(plan(`${codex} --yolo`, { executesOnThisHost: false })).toBeNull() + }) + + it('ignores an inherited opt-out the pane deletes', async () => { + vi.stubEnv('ORCA_CODEX_ISOLATE', '0') + + await expect(plan(`${codex} --yolo`, { envToDelete: ['ORCA_CODEX_ISOLATE'] })).resolves.toBe( + `${codex} --no-daemon --yolo` + ) + }) + + it('honours ORCA_CODEX_ISOLATE=0 from the pane env', () => { + expect(plan(`${codex} --yolo`, { env: { ORCA_CODEX_ISOLATE: '0' } })).toBeNull() + }) + + it('keeps the command when the binary predates --no-daemon', async () => { + const oldCodex = writeCodex('0.155/codex', HELP_WITHOUT_FLAG) + + await expect(plan(`${oldCodex} --yolo`)).resolves.toBe(`${oldCodex} --yolo`) + }) + + it.each([['codex --yolo'], ['claude --yolo'], ['/opt/bin/codexx --yolo']])( + 'leaves `%s` to the shell function or to another agent', + (command) => { + expect(plan(command)).toBeNull() + } + ) + + it('probes a bare codex on PATH for cmd.exe, which has no codex function', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + writeCodex('codex.exe', HELP_WITH_FLAG) + + await expect( + plan('codex --yolo', { + shellOverride: 'cmd.exe', + env: { PATH: dir, PATHEXT: '.exe' } + }) + ).resolves.toBe('codex --no-daemon --yolo') + }) +}) diff --git a/src/main/pty/codex-no-daemon-launch-command.ts b/src/main/pty/codex-no-daemon-launch-command.ts new file mode 100644 index 00000000000..eec86055568 --- /dev/null +++ b/src/main/pty/codex-no-daemon-launch-command.ts @@ -0,0 +1,99 @@ +import { isAbsolute, win32 as pathWin32 } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { CODEX_SHARED_SERVER_ARGS } from '../../shared/codex-shell-function' + +const CODEX_EXECUTABLE = /^codex(\.(exe|cmd|bat|ps1))?$/i +const SHARED_SERVER_ARGS: ReadonlySet = new Set(CODEX_SHARED_SERVER_ARGS) +// Why bounded: a hung binary must not hold the pane; a slow probe only costs the flag. +const HELP_PROBE_TIMEOUT_MS = 5_000 + +export type LocalCodexLaunch = { + command: string | undefined + /** False for SSH and WSL spawns: their shell's codex function probes on that host. */ + executesOnThisHost: boolean + /** Shell the provider will launch; undefined means the platform default. */ + shellOverride: string | undefined + /** Env the PTY gets on top of this process's own. */ + env: Record | undefined + /** Keys the provider removes from that merged env. */ + envToDelete?: readonly string[] + cwd: string | undefined +} + +/** + * The launch command with `--no-daemon` after the Codex executable, applying the + * shell codex function's rule (src/shared/codex-shell-function.ts) where that + * function never runs: cmd.exe defines none, and a path-named binary bypasses it. + * Everywhere else the function probes the binary the shell itself resolves after + * the user's startup files, which main cannot see. Null (synchronously) when + * nothing applies: an extra await tick would reorder the pane-spawn reservation + * races the spawn handlers arbitrate right after this. + */ +export function planCodexNoDaemonLaunch(launch: LocalCodexLaunch): Promise | null { + const { command } = launch + if (!command || !launch.executesOnThisHost) { + return null + } + const shell = + resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: launch.shellOverride + }) ?? 'posix' + const parsed = tokenizeStartupCommand(command, shell) + const executableSpan = parsed.ok ? parsed.spans[0] : undefined + if (!parsed.ok || !executableSpan || executableSpan.divergesFromShell) { + return null + } + const [executable, ...args] = parsed.tokens + if ( + !CODEX_EXECUTABLE.test(pathWin32.basename(executable)) || + (shell !== 'cmd' && !isAbsolute(executable)) + ) { + return null + } + const env = { ...process.env, ...launch.env } + for (const key of launch.envToDelete ?? []) { + delete env[key] + } + if ( + env.ORCA_CODEX_ISOLATE === '0' || + args.some((arg) => SHARED_SERVER_ARGS.has(arg) || arg.startsWith('--remote=')) + ) { + return null + } + return supportsNoDaemon(executable, env, launch.cwd).then((supported) => + supported + ? `${command.slice(0, executableSpan.end)} --no-daemon${command.slice(executableSpan.end)}` + : command + ) +} + +// Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. +async function supportsNoDaemon( + executable: string, + env: NodeJS.ProcessEnv, + cwd: string | undefined +): Promise { + const program = isAbsolute(executable) + ? executable + : await resolveCommandOnLocalPath(executable, { env, cwd }) + if (!program) { + return false + } + try { + const help = await runProcess({ + program, + args: ['--help'], + cwd, + env, + timeoutMs: HELP_PROBE_TIMEOUT_MS + }) + return help.stdout.includes('--no-daemon') + } catch { + return false + } +} diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index f0673fc9c95..72fbc5c14a5 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -13,12 +13,12 @@ import { tmpdir } from 'node:os' import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' +import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight' import { getFishCodexShellLaunchPreflight, getPosixCodexShellLaunchPreflight, - getPowerShellCodexShellLaunchPreflight, - resolveCodexShellLaunchPreflightCommand -} from './codex-shell-launch-preflight' + getPowerShellCodexShellLaunchPreflight +} from '../../shared/codex-shell-function' import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement' const roots: string[] = [] @@ -309,9 +309,8 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () // Regression for #16893: an unquoted `(type -t codex)` expands to zero words when // codex is absent, so `test` saw `= file` (2 args) and printed "Missing argument - // at index 3" on every fish pane launch. Needs a valid executable - // ORCA_CODEX_LAUNCH_PREFLIGHT so the `and` chain reaches the second `test`, and - // the real `-l -C` launch shape both shell-ready call sites use. + // at index 3" on every fish pane launch. Uses the real `-l -C` launch shape both + // shell-ready call sites use. it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => { const { bin, preflight } = createFishSandbox('orca-codex-fish-absent-') diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 87e5df75827..b6c17074d79 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -64,58 +64,3 @@ function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolea return false } } - -export function getPosixCodexShellLaunchPreflight(): string { - return `# Why: a typed alias expands inside the shell, after pane launch prep. -# Why unalias inside the substitution: an alias named codex makes command -v -# report the alias text, and the subshell leaves the user's own alias intact. -# Why || : twice — zsh alone aborts inside the substitution, but every shell's -# assignment adopts its exit status, so an absent codex trips set -e in bash too. -__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then - # Why the function reserved word: it suppresses alias expansion of the name, - # which otherwise rewrites this header at parse time and aborts the whole file. - function codex { - "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : - command codex "$@" - } -fi -unset __orca_codex_binary -` -} - -export function getFishCodexShellLaunchPreflight(): string { - return `# Why captured: an unquoted (type -t codex) expands to zero words when codex is -# absent, leaving "test = file" — fish then errors instead of failing closed. -# Quoting in place is not the fix; fish never substitutes inside double quotes. -set -l __orca_codex_type (type -t codex 2>/dev/null) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file - function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true - command codex $argv - end -end -set -e __orca_codex_type` -} - -export function getPowerShellCodexShellLaunchPreflight(): string { - return `$orcaCodexCommand = Get-Command codex -ErrorAction SilentlyContinue | Select-Object -First 1 -if ($env:ORCA_CODEX_LAUNCH_PREFLIGHT -and $orcaCodexCommand -and - $orcaCodexCommand.CommandType -in @("Application", "ExternalScript")) { - function Global:codex { - try { - & $env:ORCA_CODEX_LAUNCH_PREFLIGHT agent hooks prepare-codex *> $null - } catch { - } - $orcaCodexExecutable = Get-Command codex -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $orcaCodexExecutable) { - Write-Error "codex executable not found" - $global:LASTEXITCODE = 127 - return - } - & $orcaCodexExecutable.Source @args - $global:LASTEXITCODE = $LASTEXITCODE - } -} -Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue` -} diff --git a/src/main/pty/codex-shell-no-daemon.test.ts b/src/main/pty/codex-shell-no-daemon.test.ts new file mode 100644 index 00000000000..25ee00a962c --- /dev/null +++ b/src/main/pty/codex-shell-no-daemon.test.ts @@ -0,0 +1,353 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' +import { + getFishCodexShellLaunchPreflight, + getPosixCodexShellLaunchPreflight, + getPowerShellCodexShellLaunchPreflight +} from '../../shared/codex-shell-function' +import { resolveFishBinary } from '../../shared/fish-binary-requirement' + +const isWindows = process.platform === 'win32' +const fishLookup = resolveFishBinary() +const canRun = (command: string): boolean => + spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0 +const pwshAvailable = canRun('pwsh') + +const HELP_WITH_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-daemon Run in-process\n' +const HELP_WITHOUT_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-alt-screen\n' + +// Why argv as whole words: the rule is a whole-argument denylist (plan §4). +const ADDED: string[][] = [ + [], + ['fix the bug'], + ['exec the plan'], + ['-m', 'gpt-5', '--yolo', 'x'], + ['resume'], + ['resume', '--last'], + ['--yolo', 'resume', '--last'], + ['fork', '--last'], + ['-a', 'never', 'resume'], + ['-c', 'model=o3'], + ['archive', 'S'], + ['delete', 'S'], + ['exec', 'x'], + ['e', 'x'], + ['-m', 'x', 'exec', 'x'], + ['review'], + ['login'], + ['mcp', 'list'] +] +const UNCHANGED: string[][] = [ + ['agents'], + ['-m', 'x', 'agents'], + ['-c', 'k=v', 'agents'], + ['--image=a.png', 'agents'], + ['agents', '--remote', 'X'], + ['queue', '--thread', 'T', '--message', 'M'], + ['-c', 'k=v', 'queue'], + ['--no-daemon'], + ['resume', '--no-daemon'], + ['-m', 'x', '--no-daemon'], + ['--remote', 'unix://'], + ['--remote=ws://h:1'], + ['resume', '--remote', 'X'], + ['-m', 'agents'], + ['--', 'agents'], + ['--', '--remote'] +] +const ALL = [...ADDED, ...UNCHANGED] + +type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'powershell' +const isPowerShell = (shell: Shell): boolean => shell === 'pwsh' || shell === 'powershell' +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function writeExecutable(path: string, content: string): void { + writeFileSync(path, content) + chmodSync(path, 0o755) +} + +type Sandbox = { bin: string; codex: string; helpFile: string; helpLog: string } + +/** Fake codex: `--help` prints the help file and logs the probe; any other call prints its argv. */ +function makeSandbox(help: string): Sandbox { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + const helpFile = join(root, 'help.txt') + const helpLog = join(root, 'help.log') + writeFileSync(helpFile, help) + writeFileSync(helpLog, '') + if (isWindows) { + // Why a .cmd shim over node: the shape npm installs, and what Get-Command resolves. + writeFileSync( + join(bin, 'fake.js'), + `const fs = require('fs') +const a = process.argv.slice(2) +if (a.length === 1 && a[0] === '--help') { + fs.appendFileSync(${JSON.stringify(helpLog)}, 'help\\n') + process.stdout.write(fs.readFileSync(${JSON.stringify(helpFile)}, 'utf8')) + process.exit(0) +} +let out = ['ARGV', ...a].join('|') +if (process.env.FAKE_CODEX_READ_STDIN === '1') out += '|stdin=' + fs.readFileSync(0, 'utf8').trim() +console.log(out) +process.exit(Number(process.env.FAKE_CODEX_EXIT || 0)) +` + ) + const codex = join(bin, 'codex.cmd') + writeFileSync(codex, '@node "%~dp0fake.js" %*\r\n') + return { bin, codex, helpFile, helpLog } + } + const codex = join(bin, 'codex') + writeExecutable( + codex, + `#!/bin/sh +if [ "$#" -eq 1 ] && [ "$1" = --help ]; then echo help >> ${JSON.stringify(helpLog)}; cat ${JSON.stringify(helpFile)}; exit 0; fi +out=ARGV +for a in "$@"; do out="$out|$a"; done +[ "\${FAKE_CODEX_READ_STDIN:-}" = 1 ] && out="$out|stdin=$(cat)" +printf '%s\\n' "$out" +exit "\${FAKE_CODEX_EXIT:-0}" +` + ) + return { bin, codex, helpFile, helpLog } +} + +function helpProbes(sandbox: Sandbox): number { + return readFileSync(sandbox.helpLog, 'utf8').split('\n').filter(Boolean).length +} + +function quote(shell: Shell, word: string): string { + if (isPowerShell(shell)) { + return `'${word.replace(/'/g, "''")}'` + } + if (shell === 'fish') { + return `'${word.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + } + return `'${word.replace(/'/g, `'\\''`)}'` +} + +function codexCall(shell: Shell, argv: string[]): string { + return ['codex', ...argv.map((word) => quote(shell, word))].join(' ') +} + +function run( + shell: Shell, + script: string, + sandbox: Sandbox, + env: Record = {}, + preamble = '' +): { status: number | null; stdout: string; stderr: string } { + const template = + shell === 'fish' + ? getFishCodexShellLaunchPreflight() + : isPowerShell(shell) + ? getPowerShellCodexShellLaunchPreflight() + : getPosixCodexShellLaunchPreflight() + // Why the guard: rows include `login`, which a real codex would run against the host's account. + const guard = isPowerShell(shell) + ? `if ((Get-Command codex -CommandType Application | Select-Object -First 1).Source -ne ${quote(shell, sandbox.codex)}) { exit 97 }` + : shell === 'fish' + ? `test (command -s codex) = ${quote(shell, sandbox.codex)}; or exit 97` + : `[ "$(command -v codex)" = ${quote(shell, sandbox.codex)} ] || exit 97` + const body = `${guard}\n${preamble}\n${template}\n${script}` + // Why a file for bash/zsh: it is read line by line like a startup file, so an alias it defines applies. + const scriptFile = join(sandbox.bin, '..', 'script.sh') + writeFileSync(scriptFile, body) + const [command, args]: [string, string[]] = + shell === 'bash' + ? ['/bin/bash', ['--noprofile', '--norc', scriptFile]] + : shell === 'zsh' + ? ['/bin/zsh', ['-f', scriptFile]] + : shell === 'fish' + ? [String(fishLookup.path), ['--no-config', '-c', body]] + : [shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', body]] + const result = spawnSync(command, args, { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${sandbox.bin}${delimiter}${process.env.PATH ?? ''}`, + CODEX_HOME: join(sandbox.bin, '..', 'codex-home'), + ...env + } + }) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } +} + +function lines(output: string): string[] { + return output.trimEnd().split(/\r?\n/) +} + +function expectedLine(argv: string[], added: boolean): string { + return ['ARGV', ...(added ? ['--no-daemon'] : []), ...argv].join('|') +} + +const shells: [Shell, boolean][] = [ + ['bash', !isWindows && existsSync('/bin/bash')], + ['zsh', !isWindows && existsSync('/bin/zsh')], + ['fish', fishLookup.available], + ['pwsh', pwshAvailable], + // Why: Windows PowerShell 5.1 turns redirected native stderr into errors, unlike pwsh. + ['powershell', isWindows && canRun('powershell')] +] + +describe('codex wrapper --no-daemon rule', () => { + it('has pwsh when CI demanded it', () => { + expect(process.env.ORCA_REQUIRE_PWSH !== '1' || pwshAvailable).toBe(true) + }) + + for (const [shell, available] of shells) { + describe.skipIf(!available)(shell, () => { + it('adds --no-daemon first unless an argument is denylisted', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run(shell, ALL.map((argv) => codexCall(shell, argv)).join('\n'), sandbox) + + expect(result.stderr).toBe('') + expect(lines(result.stdout)).toEqual([ + ...ADDED.map((argv) => expectedLine(argv, true)), + ...UNCHANGED.map((argv) => expectedLine(argv, false)) + ]) + // Why: a denylisted launch must not pay for, or depend on, the --help probe. + expect(helpProbes(sandbox)).toBe(ADDED.length) + }) + + it('adds nothing when --help does not list the flag (0.155 and older)', () => { + const sandbox = makeSandbox(HELP_WITHOUT_FLAG) + const result = run(shell, ALL.map((argv) => codexCall(shell, argv)).join('\n'), sandbox) + + expect(lines(result.stdout)).toEqual(ALL.map((argv) => expectedLine(argv, false))) + }) + + it('adds nothing with ORCA_CODEX_ISOLATE=0, read on every call', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const setIsolate = (value: string): string => + isPowerShell(shell) + ? `$env:ORCA_CODEX_ISOLATE = '${value}'` + : shell === 'fish' + ? `set -gx ORCA_CODEX_ISOLATE ${value}` + : `export ORCA_CODEX_ISOLATE=${value}` + const result = run( + shell, + ['codex a', setIsolate('1'), 'codex b', setIsolate('0'), 'codex c'].join('\n'), + sandbox, + { ORCA_CODEX_ISOLATE: '0' } + ) + + expect(lines(result.stdout)).toEqual(['ARGV|a', 'ARGV|--no-daemon|b', 'ARGV|c']) + }) + + it('re-probes --help when Codex changes version mid-shell', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const swap = (help: string): string => + isPowerShell(shell) + ? `Set-Content -NoNewline -LiteralPath ${quote(shell, sandbox.helpFile)} -Value ${quote(shell, help)}` + : `printf '%s' ${quote(shell, help)} > ${quote(shell, sandbox.helpFile)}` + const result = run( + shell, + ['codex a', swap(HELP_WITHOUT_FLAG), 'codex b', swap(HELP_WITH_FLAG), 'codex c'].join( + '\n' + ), + sandbox + ) + + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|a', 'ARGV|b', 'ARGV|--no-daemon|c']) + }) + + it("keeps piped stdin for Codex and returns Codex's exit status", () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const script = isPowerShell(shell) + ? `'piped' | codex exec -\n"status=$LASTEXITCODE"` + : shell === 'fish' + ? `printf piped | codex exec -\necho status=$status` + : `printf piped | codex exec -\necho status=$?` + const result = run(shell, script, sandbox, { + FAKE_CODEX_READ_STDIN: '1', + FAKE_CODEX_EXIT: '3' + }) + + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|exec|-|stdin=piped', 'status=3']) + }) + }) + } + + for (const [shell, enableAliases] of [ + ['bash', 'shopt -s expand_aliases'], + ['zsh', 'setopt aliases'] + ] as const) { + it.skipIf(isWindows || !existsSync(`/bin/${shell}`))( + `applies a user alias named codex defined before the wrapper in ${shell}`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run( + shell, + 'codex x', + sandbox, + {}, + `${enableAliases}\nalias codex='codex --alias-flag'` + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('ARGV|--no-daemon|--alias-flag|x') + } + ) + } + + it.skipIf(isWindows || !existsSync('/bin/zsh'))( + 'creates no globals under warn_create_global', + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run('zsh', 'setopt warn_create_global no_unset\ncodex x', sandbox) + + expect(result.stderr).toBe('') + expect(result.stdout.trim()).toBe('ARGV|--no-daemon|x') + } + ) + + for (const [shell, available] of shells.filter(([name]) => isPowerShell(name))) { + it.skipIf(!available)( + `${shell} runs under StrictMode and Stop with a failing, noisy hook prep`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const prep = join(sandbox.bin, isWindows ? 'orca-prep.cmd' : 'orca-prep') + writeExecutable( + prep, + isWindows + ? '@echo prep-noise 1>&2\r\n@exit /b 7\r\n' + : '#!/bin/sh\necho prep-noise >&2\nexit 7\n' + ) + const result = run( + shell, + 'codex x\n"status=$LASTEXITCODE"', + sandbox, + { ORCA_CODEX_LAUNCH_PREFLIGHT: prep }, + [ + 'Set-StrictMode -Version Latest', + '$ErrorActionPreference = "Stop"', + '$PSNativeCommandUseErrorActionPreference = $true' + ].join('\n') + ) + + expect(result.status, result.stderr).toBe(0) + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|x', 'status=0']) + } + ) + } +}) diff --git a/src/main/pty/posix-pty-foreground-group.ts b/src/main/pty/posix-pty-foreground-group.ts index e9df7baeb66..e558f9ce156 100644 --- a/src/main/pty/posix-pty-foreground-group.ts +++ b/src/main/pty/posix-pty-foreground-group.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process' +import { notifySpawnObserver } from '../../shared/child-process/spawn-observer' const PROCESS_TABLE_LOOKUP_TIMEOUT_MS = 250 const PROCESS_TABLE_QUERY_TIMEOUT_MS = PROCESS_TABLE_LOOKUP_TIMEOUT_MS / 2 @@ -26,11 +27,18 @@ function hasUsableTty(tty: string): boolean { } function runPs(pid: number): string { - return execFileSync('ps', ['-p', String(pid), '-o', 'pid=,tpgid=,tty='], { - encoding: 'utf8', - timeout: PROCESS_TABLE_QUERY_TIMEOUT_MS, - maxBuffer: PROCESS_TABLE_MAX_BYTES - }) + const args = ['-p', String(pid), '-o', 'pid=,tpgid=,tty='] + const startedAt = performance.now() + try { + return execFileSync('ps', args, { + encoding: 'utf8', + timeout: PROCESS_TABLE_QUERY_TIMEOUT_MS, + maxBuffer: PROCESS_TABLE_MAX_BYTES + }) + } finally { + // Diagnostics only: execFileSync blocks the caller for the child's whole life. + notifySpawnObserver('ps', args, performance.now() - startedAt) + } } let ownRowCache: { pid: number; row: string } | null = null diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 1df30613076..a108a1ae55f 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -64,6 +64,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_CLI_COMMAND: 'orca-ide', ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash', ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + ORCA_CODEX_ISOLATE: '0', ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', @@ -90,6 +91,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p') expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') + expect(env.WSLENV).toContain('ORCA_CODEX_ISOLATE/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index b3047fd8a27..0044399009f 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -82,6 +82,7 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why /p: the managed CLI launcher lives in the host's userData tree. 'ORCA_WSL_CLI_DIR/p', 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', + 'ORCA_CODEX_ISOLATE/u', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts index 2a834f7cbcb..8862a76dd75 100644 --- a/src/main/qoder/workspace-trust.ts +++ b/src/main/qoder/workspace-trust.ts @@ -1,10 +1,7 @@ import { realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { join, posix } from 'node:path' +import { join } from 'node:path' import { isPlainObject, readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils' -import { parseHooksJsonText } from '../agent-hooks/hooks-json-read' -import { isENOENT } from '../ipc/filesystem-path-containment' -import type { IFilesystemProvider } from '../providers/types' // Qoder 1.1.64 writes this exact shape after accepting its folder-trust prompt. export function withQoderTrustedWorkspace( @@ -45,34 +42,3 @@ export function markQoderWorkspaceTrusted(workspacePath: string): void { writeHooksJson(configPath, updated) } } - -export async function markRemoteQoderWorkspaceTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const configDir = posix.join(remoteHome, '.qoder') - const configPath = posix.join(configDir, 'settings.json') - let config: Record | null - try { - const result = await fsProvider.readFile(configPath) - if (result.isBinary) { - return - } - config = parseHooksJsonText(result.content) - } catch (error) { - if (!isENOENT(error)) { - return - } - config = {} - } - if (!config) { - return - } - const updated = withQoderTrustedWorkspace(config, workspacePath) - if (!updated || updated === config) { - return - } - await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`) -} diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts deleted file mode 100644 index 315eba73aa0..00000000000 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * agent-browser forks a daemon per browser tab that Orca holds no handle on, and - * `destroyAllSessions` closes each one by spawning another agent-browser child — - * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and - * every open tab's daemon outlived the app (#16367). - */ -const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') - -function teardownBarrierMembers(): string { - const start = source.indexOf('settleTeardownWithinDeadline([') - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('])', start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('quit teardown of agent-browser daemons', () => { - it('joins the will-quit teardown barrier', () => { - expect(teardownBarrierMembers()).toContain("{ name: 'browser', promise: browserShutdown }") - }) - - it('captures the destroyAllSessions promise instead of firing and forgetting', () => { - expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ - ) - // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). - expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) - }) -}) diff --git a/src/main/rate-limits/codex-fetcher-process-contract.test.ts b/src/main/rate-limits/codex-fetcher-process-contract.test.ts index 8d067a686ad..b698c706c6a 100644 --- a/src/main/rate-limits/codex-fetcher-process-contract.test.ts +++ b/src/main/rate-limits/codex-fetcher-process-contract.test.ts @@ -2,18 +2,27 @@ 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' -import type * as Win32Utils from '../win32-utils' +import type * as RunProcess from '../../shared/child-process/run-process' -const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ - getSpawnArgsForWindowsMock: vi.fn(), +const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ({ + stubScript: { path: '' }, ptySpawnMock: vi.fn(), resolveCodexCommandMock: vi.fn() })) -vi.mock('../win32-utils', async (importOriginal) => ({ - ...(await importOriginal()), - getSpawnArgsForWindows: getSpawnArgsForWindowsMock -})) +// Runs the node stub in place of the resolved CLI, through the real chokepoint. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawnProcess: (spec: Parameters[0]) => + actual.spawnProcess({ + ...spec, + program: process.execPath, + args: [stubScript.path, ...(spec.args ?? [])] + }) + } +}) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock @@ -105,10 +114,7 @@ describe('Codex rate-limit process contract', () => { previousExpectedHome = process.env.ORCA_EXPECTED_CODEX_HOME process.env.ORCA_EXPECTED_CODEX_HOME = join(tempRoot, 'managed-codex-home') resolveCodexCommandMock.mockReturnValue('codex') - getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ - spawnCmd: process.execPath, - spawnArgs: [stubPath, ...args] - })) + stubScript.path = stubPath }) afterEach(() => { diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts index ee874ef5942..441be8d836d 100644 --- a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -2,19 +2,28 @@ 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' -import type * as Win32Utils from '../win32-utils' +import type * as RunProcess from '../../shared/child-process/run-process' import { isCodexAuthError } from '../../shared/codex-auth-errors' -const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ - getSpawnArgsForWindowsMock: vi.fn(), +const { ptySpawnMock, resolveCodexCommandMock, stubScript } = vi.hoisted(() => ({ + stubScript: { path: '' }, ptySpawnMock: vi.fn(), resolveCodexCommandMock: vi.fn() })) -vi.mock('../win32-utils', async (importOriginal) => ({ - ...(await importOriginal()), - getSpawnArgsForWindows: getSpawnArgsForWindowsMock -})) +// Runs the node stub in place of the resolved CLI, through the real chokepoint. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawnProcess: (spec: Parameters[0]) => + actual.spawnProcess({ + ...spec, + program: process.execPath, + args: [stubScript.path, ...(spec.args ?? [])] + }) + } +}) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock @@ -62,10 +71,7 @@ describe('Codex RPC exit diagnostics', () => { stubPath = join(tempRoot, 'codex-exit-stub.cjs') writeFileSync(stubPath, STUB_CODEX_SOURCE) resolveCodexCommandMock.mockReturnValue('codex') - getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ - spawnCmd: process.execPath, - spawnArgs: [stubPath, ...args] - })) + stubScript.path = stubPath }) afterEach(() => { diff --git a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts index 0353bd4c31d..d825bb65fd8 100644 --- a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts +++ b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RunProcess from '../../shared/child-process/run-process' const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ childSpawnMock: vi.fn(), @@ -12,6 +13,12 @@ const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = })) vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +// The chokepoint is the seam: assertions see what the fetcher asked for, before shim resolution. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + spawnProcess: (spec: { program: string; args?: readonly string[] }) => + childSpawnMock(spec.program, spec.args ?? [], spec) +})) vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) @@ -79,18 +86,17 @@ describe('codex rate-limit spawn runtime pairing', () => { await vi.advanceTimersByTimeAsync(0) const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv - // Guards the argument choice: pairing spawnCmd (cmd.exe on win32) rather than - // the resolved CLI silently reverts the ABI fix (stablyai/orca#10932). + // Guards the argument choice: pairing anything but the resolved CLI silently + // reverts the ABI fix (stablyai/orca#10932). expect(spawnEnv.PATH?.split(delimiter)[0]).toBe(bin) rpcChild.emit('close') await resultPromise }) - it('pairs the resolved CLI on win32, where the spawn command is cmd.exe', async () => { - // Why win32 specifically: on posix getSpawnArgsForWindows returns the CLI - // itself, so pairing the spawn command instead of the resolved CLI is - // indistinguishable. Only here does the wrong argument become cmd.exe. + it('hands an npm .cmd shim to the spawn chokepoint unwrapped, paired with its node', async () => { + // Why win32 specifically: only here could a pre-wrap turn the program into + // cmd.exe, which hides the shim from spawnProcess's resolver. const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) try { @@ -103,7 +109,7 @@ describe('codex rate-limit spawn runtime pairing', () => { await vi.advanceTimersByTimeAsync(0) const spawnCommand = childSpawnMock.mock.calls[0]?.[0] as string - expect(spawnCommand.toLowerCase()).toContain('cmd.exe') + expect(spawnCommand).toBe(cli) const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv expect((spawnEnv.Path ?? spawnEnv.PATH)?.split(';')[0]).toBe(bin) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index be6edd3b3fb..7d5b7423f7c 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RunProcess from '../../shared/child-process/run-process' const { childSpawnMock, @@ -22,6 +23,13 @@ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +// The chokepoint is the seam: assertions see what the fetcher asked for, before shim resolution. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + spawnProcess: (spec: { program: string; args?: readonly string[] }) => + childSpawnMock(spec.program, spec.args ?? [], spec) +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) @@ -51,7 +59,6 @@ vi.mock('./codex-auth-presence', () => ({ import { fetchCodexRateLimits } from './codex-fetcher' import { probeCodexAuthPresence } from './codex-auth-presence' import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' -import { getCmdExePath } from '../win32-utils' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' function makeDisposable() { @@ -787,13 +794,9 @@ describe('fetchCodexRateLimits', () => { await resultPromise const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] - expect(spawnFile).toBe(getCmdExePath()) - expect(spawnArgs).toEqual([ - '/d', - '/c', - codexCommand, - ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS - ]) + // Pre-wrapping in cmd.exe would hide the npm shim from spawnProcess's resolver. + expect(spawnFile).toBe(codexCommand) + expect(spawnArgs).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) expect(spawnOptions).toEqual( expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 1217500fb2c..804cd22c660 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -1,5 +1,4 @@ import type { CodexRateLimitResetOutcome, ProviderRateLimits } from '../../shared/rate-limit-types' -import { spawn } from 'node:child_process' import { isCodexAuthError } from '../../shared/codex-auth-errors' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -18,7 +17,8 @@ import { import { isCodexStateDbBackfillPending } from '../codex/codex-state-db' import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' +import { spawnProcess } from '../../shared/child-process/run-process' +import { getCmdExePath } from '../win32-utils' import { probeCodexAuthPresence } from './codex-auth-presence' import { fetchCodexRateLimitsViaBackend, @@ -109,19 +109,17 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise ({ session: { fromPartition: sessionFromPartitionMock } })) -import { - extractMiniMaxCookieValue, - fetchMiniMaxRateLimits, - normalizeMiniMaxCookieHeader, - redactMiniMaxSecret -} from './minimax-fetcher' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' @@ -646,54 +641,3 @@ describe('fetchMiniMaxRateLimits', () => { }) }) }) - -describe('normalizeMiniMaxCookieHeader', () => { - it('preserves all cookie pairs from a browser Cookie header', () => { - const normalized = normalizeMiniMaxCookieHeader( - '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' - ) - expect(normalized).toBe( - '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' - ) - }) - - it('normalizes quoted MiniMax cookie storage syntax', () => { - expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( - '_token=tok; minimax_group_id_v2=42' - ) - }) - - it('accepts a copied Cookie header line', () => { - expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( - 'session=abc; other=xyz' - ) - }) -}) - -describe('extractMiniMaxCookieValue', () => { - it('returns the value for a given cookie name', () => { - expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345') - }) - it('returns null when the name is absent', () => { - expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() - }) -}) - -describe('redactMiniMaxSecret', () => { - it('redacts _token values', () => { - expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]') - expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF') - }) - it('redacts minimax_group_id_v2 values', () => { - expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') - }) - it('redacts MiniMax anti-bot cookie values', () => { - const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"') - expect(redacted).not.toContain('secret') - expect(redacted).toContain('ak_bmsc=[REDACTED]') - expect(redacted).toContain('bm_sv:[REDACTED]') - }) - it('redacts Cookie: header lines', () => { - expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]') - }) -}) diff --git a/src/main/refused-tree-kill-root-termination.test.ts b/src/main/refused-tree-kill-root-termination.test.ts index dbd3a10f191..f6bb531165a 100644 --- a/src/main/refused-tree-kill-root-termination.test.ts +++ b/src/main/refused-tree-kill-root-termination.test.ts @@ -1,9 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock, execFileMock, queryWindowsProcessDescendantsMock } = vi.hoisted(() => ({ +const { spawnMock, execFileMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), - execFileMock: vi.fn(), - queryWindowsProcessDescendantsMock: vi.fn() + execFileMock: vi.fn() })) vi.mock('node:child_process', async (importOriginal) => ({ @@ -12,9 +11,6 @@ vi.mock('node:child_process', async (importOriginal) => ({ execFile: execFileMock })) vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), on: vi.fn() } })) -vi.mock('./providers/windows-foreground-process-rows', () => ({ - queryWindowsProcessDescendants: queryWindowsProcessDescendantsMock -})) import { getAppEnvironment, @@ -36,7 +32,6 @@ import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tre import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' import { signalProcessTree } from '../shared/child-process/process-tree-termination' import { killSourceControlAgentProcess } from './text-generation/source-control-local-process' -import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes' /** A pid Electron reports as one of ours: every gate below must refuse it. */ const RENDERER_PID = 1001 @@ -72,7 +67,6 @@ beforeEach(() => { installMainProcessTreeKillGate() spawnMock.mockReset() execFileMock.mockReset() - queryWindowsProcessDescendantsMock.mockReset() spawnMock.mockReturnValue({ on: vi.fn(), once: vi.fn(), unref: vi.fn(), kill: vi.fn() }) }) @@ -175,35 +169,3 @@ describe('a refused tree-kill still terminates the root it owns', () => { processKill.mockRestore() }) }) - -/** - * The one gated site with nothing to fall back to: the roots it kills are found - * by a process-table walk, not spawned here, so there is no child handle. A - * refusal must then be visible — the refusal crumb is written and the turn is - * reported as not cancelled — rather than resolving as if the tree had gone. - */ -describe('a refused tree-kill with no handle to fall back to', () => { - it('reports the codex turn as not cancelled and records the refused added root', async () => { - const appServerPid = 500 - const addedRoot = { - pid: RENDERER_PID, - ppid: appServerPid, - name: 'node.exe', - command: 'node', - depth: 1 - } - queryWindowsProcessDescendantsMock.mockResolvedValue([addedRoot]) - - await expect( - terminateCodexTurnProcesses(appServerPid, { platform: 'win32', identities: new Map() }) - ).resolves.toBe(false) - - expect(execFileMock).not.toHaveBeenCalled() - expect(getCrashBreadcrumbSnapshot()).toEqual([ - expect.objectContaining({ - name: 'self_tree_kill_refused_own_chromium', - data: expect.objectContaining({ pid: RENDERER_PID, site: 'codex-turn-added-roots' }) - }) - ]) - }) -}) diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts deleted file mode 100644 index 4b0692bb142..00000000000 --- a/src/main/remote-agent-trust-presets.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - getActiveMultiplexer: vi.fn(), - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./ssh/ssh-target-registry', () => ({ - getActiveMultiplexer: mocks.getActiveMultiplexer -})) - -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: mocks.getSshFilesystemProvider -})) - -const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') - -function makeFsProvider(overrides: Record = {}) { - return { - realpath: vi.fn(async (path: string) => `/real${path}`), - readFile: vi.fn(async () => ({ content: '', isBinary: false })), - createDir: vi.fn(async () => undefined), - writeFile: vi.fn(async () => undefined), - stat: vi.fn(async () => { - throw new Error('missing') - }), - ...overrides - } -} - -describe('markRemoteAgentWorkspaceTrusted', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: '/home/u/' })) - }) - }) - - it('writes Codex trust to the remote home and canonicalized workspace path', async () => { - const fsProvider = makeFsProvider() - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(mocks.getActiveMultiplexer).toHaveBeenCalledWith('ssh-1') - expect(fsProvider.realpath).toHaveBeenCalledWith('/repo/worktree') - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.codex') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/u/.codex/config.toml', - expect.stringContaining('[projects."/real/repo/worktree"]') - ) - }) - - it('writes Codex trust when the remote home is a Windows absolute path', async () => { - const fsProvider = makeFsProvider({ - realpath: vi.fn(async () => 'C:/Users/alice/platform') - }) - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: 'C:\\Users\\alice\\' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-windows', - workspacePath: 'C:\\Users\\alice\\platform' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith('C:/Users/alice/.codex') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:/Users/alice/.codex/config.toml', - expect.stringContaining('[projects."C:/Users/alice/platform"]') - ) - }) - - it('writes Cursor trust marker on the remote host', async () => { - const fsProvider = makeFsProvider() - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'cursor', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.cursor/projects/real-repo-worktree') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/u/.cursor/projects/real-repo-worktree/.workspace-trusted', - expect.stringContaining('"workspacePath": "/real/repo/worktree"') - ) - }) - - it('sanitizes Windows path characters in remote Cursor trust marker paths', async () => { - const fsProvider = makeFsProvider({ - realpath: vi.fn(async () => 'C:/Users/alice/platform') - }) - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: 'C:/Users/alice/' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'cursor', - connectionId: 'ssh-windows', - workspacePath: 'C:\\Users\\alice\\platform' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith( - 'C:/Users/alice/.cursor/projects/C-Users-alice-platform' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:/Users/alice/.cursor/projects/C-Users-alice-platform/.workspace-trusted', - expect.stringContaining('"workspacePath": "C:/Users/alice/platform"') - ) - }) - - it('appends Copilot trusted folder remotely without clobbering config keys', async () => { - const writeFile = vi.fn(async (_filePath: string, _content: string) => undefined) - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => ({ - content: JSON.stringify({ firstLaunchAt: '2026-01-01', trustedFolders: ['/old'] }), - isBinary: false - })), - writeFile - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'copilot', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.copilot') - const written = writeFile.mock.calls[0]?.[1] - expect(typeof written).toBe('string') - expect(JSON.parse(written as string)).toEqual({ - firstLaunchAt: '2026-01-01', - trustedFolders: ['/old', '/real/repo/worktree'] - }) - }) - - it('does nothing when the SSH home cannot be resolved safely', async () => { - const fsProvider = makeFsProvider() - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: 'relative/home' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(fsProvider.writeFile).not.toHaveBeenCalled() - }) - - it.each(['codex', 'copilot'] as const)( - 'leaves the remote %s config untouched when the read fails', - async (preset) => { - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => { - throw new Error('file stream stalled') - }) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await expect( - markRemoteAgentWorkspaceTrusted({ preset, connectionId: 'ssh-1', workspacePath: '/repo' }) - ).rejects.toThrow() - - expect(fsProvider.writeFile).not.toHaveBeenCalled() - } - ) - - it.each(['codex', 'copilot'] as const)( - 'leaves the remote %s config untouched when the read comes back binary', - async (preset) => { - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => ({ content: 'AAEC', isBinary: true })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await expect( - markRemoteAgentWorkspaceTrusted({ preset, connectionId: 'ssh-1', workspacePath: '/repo' }) - ).rejects.toThrow() - - expect(fsProvider.writeFile).not.toHaveBeenCalled() - } - ) - - it('creates the remote Codex config when the relay reports it missing', async () => { - // The relay rebuilds remote errors without Node's .code; only the message survives. - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => { - throw new Error("ENOENT: no such file or directory, stat '/home/u/.codex/config.toml'") - }) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-1', - workspacePath: '/repo' - }) - - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/u/.codex/config.toml', - '[projects."/real/repo"]\ntrust_level = "trusted"\n' - ) - }) -}) - -describe('remote Qoder trust', () => { - it('preserves settings and hooks while trusting the execution host canonical folder', async () => { - const original = { - model: 'custom', - hooks: { Stop: [] }, - permissions: { trustDirectories: ['/old'], other: true } - } - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => ({ content: JSON.stringify(original), isBinary: false })) - }) - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: '/home/remote' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - await markRemoteAgentWorkspaceTrusted({ - preset: 'qoder', - connectionId: 'ssh-qoder', - workspacePath: '/folder' - }) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/remote/.qoder/settings.json', - `${JSON.stringify( - { - ...original, - permissions: { ...original.permissions, trustDirectories: ['/old', '/real/folder'] } - }, - null, - 2 - )}\n` - ) - }) - - it.each(['malformed', 'denied', 'binary', 'trusted'])( - 'does not overwrite %s remote settings', - async (kind) => { - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => { - if (kind === 'denied') { - throw Object.assign(new Error('denied'), { code: 'EACCES' }) - } - return { - content: - kind === 'trusted' - ? JSON.stringify({ permissions: { trustDirectories: ['/real/folder'] } }) - : '{broken', - isBinary: kind === 'binary' - } - }) - }) - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: '/home/remote' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - await markRemoteAgentWorkspaceTrusted({ - preset: 'qoder', - connectionId: 'ssh-qoder', - workspacePath: '/folder' - }) - expect(fsProvider.writeFile).not.toHaveBeenCalled() - } - ) -}) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts deleted file mode 100644 index 021f18683ca..00000000000 --- a/src/main/remote-agent-trust-presets.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { markRemoteQoderWorkspaceTrusted } from './qoder/workspace-trust' -import type { AgentTrustPreset } from './agent-trust-presets' -import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' -import { getActiveMultiplexer } from './ssh/ssh-target-registry' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' -import type { FileReadResult, IFilesystemProvider } from './providers/types' -import { isENOENT } from './ipc/filesystem-path-containment' -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathSeparators -} from '../shared/cross-platform-path' - -export async function markRemoteAgentWorkspaceTrusted(args: { - preset: AgentTrustPreset - connectionId: string - workspacePath: string -}): Promise { - const home = await resolveRemoteHome(args.connectionId) - const fsProvider = getSshFilesystemProvider(args.connectionId) - if (!home || !fsProvider) { - return - } - - const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath) - if (args.preset === 'qoder') { - await markRemoteQoderWorkspaceTrusted(fsProvider, home, workspacePath) - } else if (args.preset === 'codex') { - await markRemoteCodexProjectTrusted(fsProvider, home, workspacePath) - } else if (args.preset === 'cursor') { - await markRemoteCursorWorkspaceTrusted(fsProvider, home, workspacePath) - } else if (args.preset === 'copilot') { - await markRemoteCopilotFolderTrusted(fsProvider, home, workspacePath) - } - // KNOWN GAP: 'antigravity' is deliberately absent. The local preset writes - // ~/.gemini/antigravity-cli/settings.json, and the remote equivalent has not been verified - // against an SSH execution host, so an agy worker launched over SSH still raises its - // first-launch trust prompt and will stall at agent_readiness. Falling through silently - // matches the pre-existing behaviour for agy; it is recorded here rather than left as an - // unexplained omission. Mirror markRemoteCopilotFolderTrusted once it can be tested. -} - -async function resolveRemoteHome(connectionId: string): Promise { - const mux = getActiveMultiplexer(connectionId) - if (!mux || mux.isDisposed?.()) { - return null - } - const result = (await mux.request('session.resolveHome', { path: '~' })) as { - resolvedPath?: unknown - } - const home = - typeof result.resolvedPath === 'string' - ? normalizeRuntimePathSeparators(result.resolvedPath.trim()) - : '' - return home && - (home.startsWith('/') || isWindowsAbsolutePathLike(home)) && - !hasRemotePathControlCharacter(home) - ? home.replace(/\/$/, '') - : null -} - -function hasRemotePathControlCharacter(value: string): boolean { - return value.includes(String.fromCharCode(0)) || value.includes('\r') || value.includes('\n') -} - -async function canonicalizeRemoteWorkspacePath( - fsProvider: IFilesystemProvider, - workspacePath: string -): Promise { - try { - return await fsProvider.realpath(workspacePath) - } catch { - return workspacePath - } -} - -async function readRemoteTextFile( - fsProvider: IFilesystemProvider, - filePath: string -): Promise { - let result: FileReadResult - try { - result = await fsProvider.readFile(filePath) - } catch (error) { - // Why: only definitive absence may seed empty; a failed read would otherwise overwrite the user's config. - if (isENOENT(error)) { - return '' - } - throw error - } - if (result.isBinary) { - throw new Error(`Refusing to rewrite non-text remote config: ${filePath}`) - } - return result.content -} - -async function markRemoteCodexProjectTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const codexDir = `${remoteHome}/.codex` - const configPath = `${codexDir}/config.toml` - const existing = await readRemoteTextFile(fsProvider, configPath) - const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted', { - // Why: workspacePath was resolved by the remote filesystem provider; local - // realpath would canonicalize the wrong machine on SSH. - alreadyCanonical: true - }) - if (updated === existing) { - return - } - await fsProvider.createDir(codexDir) - await fsProvider.writeFile(configPath, updated) -} - -async function markRemoteCursorWorkspaceTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/:*?"<>|]+/g, '-') - if (!slug) { - return - } - const trustDir = `${remoteHome}/.cursor/projects/${slug}` - const trustFile = `${trustDir}/.workspace-trusted` - try { - await fsProvider.stat(trustFile) - return - } catch { - // Missing marker: write the same shape the local trust preset writes. - } - await fsProvider.createDir(trustDir) - await fsProvider.writeFile( - trustFile, - `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` - ) -} - -async function markRemoteCopilotFolderTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const configDir = `${remoteHome}/.copilot` - const configPath = `${configDir}/config.json` - const raw = await readRemoteTextFile(fsProvider, configPath) - let config: Record = {} - if (raw.trim()) { - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - config = parsed as Record - } - } catch { - return - } - } - const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] - if (existing.includes(workspacePath)) { - return - } - config.trustedFolders = [...existing.filter((entry) => typeof entry === 'string'), workspacePath] - await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) -} diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.meta.json new file mode 100644 index 00000000000..ea7309f786d --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-29T04:07:03.525Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox", + "-c", + "tui.fullscreen_transcript=true" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1, default tui animations, fullscreen transcript (the 0.157 default). One turn running `sleep 25`, sent once the composer had been quiet 3s; cut 6s past the last busy frame, before exit. .timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.timing.json b/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.timing.json new file mode 100644 index 00000000000..1617fedd5fc --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.timing.json @@ -0,0 +1 @@ +{"promptSentAtMs":5158.6,"chunks":[[78.3,55],[329.8,80],[329.9,9],[330.2,14],[330.2,552],[330.3,43],[330.5,43],[330.9,43],[332.1,43],[333.8,14],[333.8,408],[333.9,43],[334.4,43],[341,43],[341.3,43],[341.6,43],[353.9,43],[873.7,43],[873.8,43],[874,43],[874.3,35],[874.3,8],[874.5,43],[895.6,43],[900.8,43],[902.2,12],[902.6,43],[903.1,14],[903.2,465],[912.4,43],[942.2,35],[942.3,8],[1007.7,14],[1042.8,287],[1042.9,8],[1043.9,43],[1078.5,12],[1080,35],[1080.1,8],[1297.3,43],[1341.1,43],[4357.1,43],[4357.2,87],[4357.4,43],[4357.5,43],[4357.6,43],[4357.7,43],[4357.8,43],[4357.9,43],[4358,43],[4358.1,43],[4358.2,43],[4358.4,43],[4358.5,43],[4358.6,43],[4358.7,43],[4358.8,43],[4358.9,43],[4359,43],[4359.1,43],[4359.2,43],[4359.3,43],[4359.4,43],[4359.7,86],[4359.7,35],[4359.7,8],[4359.8,43],[4359.9,43],[4360,43],[4360.2,43],[4360.3,43],[4360.4,43],[4360.5,43],[4360.6,43],[4360.7,43],[4360.8,43],[4360.9,43],[4361,43],[4361.1,43],[4361.2,43],[4361.3,43],[4361.4,43],[4361.5,43],[4361.6,43],[4361.7,43],[4361.8,43],[4361.9,43],[4362,43],[4362.1,43],[4362.2,43],[4362.4,43],[4362.5,43],[4362.6,43],[4362.7,43],[4362.9,43],[4363,43],[4363.1,43],[4363.2,43],[4363.3,43],[4363.4,43],[4363.5,43],[4363.7,43],[4363.8,43],[4363.9,43],[4364,43],[4364.1,43],[4364.2,43],[4364.3,43],[4364.4,43],[4364.5,43],[4364.6,43],[4364.7,43],[4364.9,43],[4388.8,177],[4388.8,8],[5158.9,44],[5159.3,14],[5159.4,320],[5163.8,43],[5172,43],[5188,14],[5189.3,163],[5189.5,8],[5222.5,14],[5222.7,43],[5256.2,43],[5291.7,43],[5325.2,14],[5325.3,43],[5359,43],[5392.5,43],[5419,14],[5419.3,35],[5419.3,8],[5441.1,43],[5450.8,35],[5450.9,8],[5472.1,43],[5473.6,18],[5482.2,89],[5516.7,18],[5516.9,35],[5516.9,8],[5550,43],[5585.2,43],[5619.9,18],[5620.1,35],[5620.1,8],[5655.6,35],[5655.7,8],[5689.8,43],[5724.5,18],[5724.6,43],[5759.6,43],[5793,83],[5827.1,18],[5827.2,43],[5860.5,83],[5895.6,43],[5930.6,18],[5930.8,43],[5964.5,83],[5999.7,43],[6034.4,18],[6034.5,43],[6068,75],[6068.1,8],[6103.8,18],[6104,43],[6139,43],[6171.6,83],[6204.5,18],[6204.7,54],[6204.7,8],[6237.7,83],[6272.7,43],[6306.9,18],[6307.1,43],[6341.3,83],[6375,43],[6409.9,18],[6410.1,43],[6444,75],[6444,8],[6478.6,43],[6512.1,18],[6512.3,43],[6546.8,83],[6581.9,43],[6614.2,18],[6614.3,43],[6649.8,83],[6685.6,43],[6719.9,18],[6720.1,43],[6756.1,43],[6791.2,43],[6826.2,18],[6826.3,35],[6826.4,8],[6852.3,43],[6885.3,43],[6918.4,18],[6918.6,35],[6918.6,8],[6951.1,43],[6985.6,43],[7020.6,18],[7020.7,43],[7054.8,43],[7089.5,43],[7123.1,18],[7123.2,43],[7158.4,43],[7194,62],[7229.1,18],[7229.2,43],[7264.3,43],[7299.8,43],[7342.4,18],[7342.7,43],[7376.8,43],[7409.4,18],[7410,43],[7443.7,43],[7478.8,43],[7512.2,18],[7512.4,43],[7547.7,43],[7581.5,43],[7592.2,43],[7626.3,18],[7626.5,43],[7660.1,43],[7696.8,43],[7733.1,18],[7733.3,43],[7766.6,83],[7800.9,43],[7812.7,18],[7812.8,43],[7846.2,43],[7881,83],[7914.4,18],[7914.6,35],[7914.6,8],[7949.1,43],[7983.5,83],[8019.1,61],[8052,83],[8085.7,43],[8119,18],[8119.2,43],[8153.4,83],[8190.7,62],[8222.1,18],[8222.3,43],[8256,83],[8288.6,43],[8321.8,18],[8321.9,43],[8355,83],[8389.2,43],[8422.9,18],[8423.2,43],[8456.5,83],[8489.9,43],[8522.1,18],[8522.2,43],[8560.2,83],[8593.7,43],[8629.1,101],[8663.2,43],[8698.2,43],[8732.2,18],[8732.3,43],[8766.6,43],[8800.7,43],[8834.1,18],[8834.3,35],[8834.3,8],[8868.1,43],[8901.7,43],[8941.9,18],[8942.1,35],[8942.1,8],[8989.7,43],[9038.5,18],[9039,43],[9076.5,43],[9099.3,43],[9134.4,18],[9134.6,43],[9148.5,35],[9148.6,8],[9181.7,43],[9215.8,18],[9215.9,54],[9215.9,8],[9249.3,43],[9285.3,43],[9303,142],[9339.3,43],[9372,43],[9406.7,18],[9406.8,43],[9441.1,43],[9478.8,43],[9514.6,18],[9514.7,43],[9548.5,43],[9583.9,43],[9628.8,18],[9629,35],[9629,8],[9662.2,43],[9702.5,61],[9738.2,43],[9773.7,83],[9808,18],[9808.2,35],[9808.2,8],[9844.1,43],[9879.8,83],[9916.4,18],[9916.7,43],[9952.3,83],[9989.8,43],[10039.3,18],[10042.2,43],[10077.3,83],[10110.8,18],[10111,43],[10148.1,83],[10183.3,43],[10226.6,18],[10227,62],[10281.4,83],[10314.8,18],[10315,43],[10349,83],[10381.7,43],[10428.5,18],[10428.6,69],[10428.7,14],[10464.4,43],[10500.6,43],[10557.6,18],[10557.7,83],[10591.7,43],[10626.4,101],[10666.4,43],[10700.1,43],[10744.3,18],[10744.5,43],[10781.1,43],[10813.2,18],[10813.4,35],[10813.4,8],[10847.1,43],[10955.4,18],[10955.5,35],[10955.6,8],[11018.5,18],[11018.7,43],[11052.1,43],[11085.1,43],[11118.1,18],[11118.2,43],[11153.8,43],[11186.2,43],[11220.4,18],[11220.6,62],[11255,43],[11289,43],[11322.2,18],[11322.9,43],[11356.2,43],[11389.7,43],[11423.4,18],[11423.6,43],[11457.7,43],[11491.8,43],[11525.6,18],[11525.7,35],[11525.8,8],[11558.4,43],[11591.8,43],[11652.2,18],[11652.4,43],[11695.6,43],[11729.1,18],[11729.3,43],[11789.3,83],[11845.3,18],[11845.6,43],[11882,83],[11918.7,18],[11918.9,43],[11951.6,83],[11987.3,43],[12038.6,18],[12038.9,43],[12076.5,83],[12109,18],[12109.2,43],[12154.6,83],[12195.6,54],[12195.7,8],[12230.6,18],[12230.9,43],[12263.8,83],[12309.3,61],[12343.1,83],[12378.3,43],[12421.6,18],[12422,43],[12457,83],[12492.6,43],[12535.3,18],[12538.6,83],[12596,43],[12631.3,101],[12726.4,18],[12727.1,43],[12831.3,18],[12831.5,43],[12866,43],[12900.1,43],[12935.5,18],[12936.1,43],[12969.6,43],[13008.6,18],[13008.8,43],[13042.5,43],[13078,43],[13112.9,18],[13113.1,43],[13147.7,43],[13181.9,43],[13226.9,18],[13227.1,62],[13303.6,18],[13303.9,72],[13304,8],[13304.4,35],[13304.4,8],[13304.5,14],[13307.2,34],[13366.5,96],[13447.3,77],[13483.8,43],[13518.6,34],[13518.8,43],[13567.2,43],[13632.6,34],[13632.8,43],[13673.3,43],[13710,34],[13710.2,35],[13710.2,8],[13746.5,43],[13786.5,83],[13821.1,34],[13821.3,43],[13856.8,83],[13891.2,43],[13939.3,77],[14044.1,34],[14044.3,83],[14096.1,75],[14096.3,8],[14131.5,34],[14131.7,43],[14215.6,34],[14215.7,14],[14215.8,99],[14262.3,83],[14320.3,34],[14320.7,35],[14320.7,8],[14404.6,34],[14404.8,83],[14490.4,83],[14523.5,34],[14523.7,75],[14523.7,8],[14556.9,43],[14601.9,34],[14602.1,43],[14656.3,83],[14700.4,43],[14743.3,34],[14743.8,43],[14800.1,43],[14854.5,34],[14854.6,43],[14897.5,43],[14932,34],[14932.2,43],[14977.6,43],[15021.9,77],[15081.3,43],[15115.7,34],[15116.1,43],[15152.2,43],[15187,43],[15241,34],[15241.1,183],[15295.3,43],[15376.1,34],[15377.8,43],[15431.7,34],[15431.8,43],[15478.1,43],[15518.5,77],[15553.5,43],[15623.3,77],[15689.1,43],[15720.5,77],[15754.9,43],[15795.5,83],[15828.8,34],[15828.9,43],[15863.3,83],[15898,43],[15940.9,34],[15941,43],[15985.1,83],[16031.2,34],[16031.3,43],[16065.5,83],[16108.9,34],[16109.1,43],[16196.9,113],[16231.8,77],[16269.9,83],[16311.1,34],[16311.2,43],[16390.2,83],[16423.6,34],[16423.8,43],[16459.2,75],[16459.3,8],[16497.1,43],[16561.2,34],[16561.5,83],[16607.1,34],[16607.3,43],[16663.9,83],[16698.5,43],[16742.7,34],[16743,43],[16815.7,34],[16815.9,43],[16852.1,43],[16886.1,43],[16966.2,34],[16966.4,43],[17002.4,34],[17002.6,35],[17002.7,8],[17035.8,43],[17073.9,43],[17107.4,77],[17152.4,43],[17187.9,43],[17235.6,96],[17320.3,34],[17320.5,43],[17365.5,43],[17405.9,34],[17412.1,43],[17491.8,43],[17528.8,34],[17529,43],[17569.5,43],[17634.6,34],[17634.8,43],[17671.9,43],[17715.6,34],[17715.8,43],[17750.7,43],[17793.2,83],[17825.6,34],[17825.8,43],[17864.4,83],[17902.4,43],[17934.9,34],[17936.1,43],[18011.4,34],[18011.6,69],[18011.6,14],[18045.4,43],[18078.8,83],[18114.2,34],[18114.4,43],[18153.8,83],[18186.1,43],[18220.3,34],[18220.5,54],[18220.6,8],[18256.6,83],[18315.2,34],[18315.3,43],[18349.2,83],[18394.5,43],[18428.3,34],[18428.5,75],[18428.5,8],[18463.6,43],[18497.1,43],[18531.3,34],[18531.4,83],[18566.3,43],[18612.3,34],[18613.2,43],[18647.6,69],[18648,14],[18681.3,43],[18717.6,34],[18717.7,43],[18752.8,43],[18787.1,43],[18821.9,77],[18885.1,43],[18955.7,34],[18955.8,43],[19003.4,34],[19003.5,43],[19051.9,43],[19092.8,43],[19125.7,34],[19125.9,35],[19125.9,8],[19171,43],[19219.5,34],[19219.7,54],[19219.7,8],[19284.6,43],[19328.3,77],[19374.3,43],[19411.8,77],[19450.3,43],[19484.5,43],[19540.6,77],[19578.3,43],[19623.8,34],[19624.1,43],[19660.9,43],[19696.3,43],[19783.5,34],[19783.7,75],[19783.7,8],[19851.9,34],[19852.2,43],[19888.5,83],[19922.5,34],[19922.7,43],[19957.9,83],[19991.9,43],[20026.9,34],[20027.1,43],[20063.2,83],[20103,34],[20103.2,35],[20103.2,8],[20137.4,43],[20187.5,83],[20228.8,34],[20229,48],[20229,14],[20263.8,83],[20297.7,43],[20331.7,34],[20331.8,35],[20331.8,8],[20376.6,83],[20410.5,34],[20411.2,43],[20514.6,34],[20514.6,83],[20548.4,83],[20583.5,43],[20617.4,34],[20617.6,35],[20617.6,8],[20652.3,83],[20685.6,43],[20721.4,34],[20721.5,43],[20755.3,43],[20790,43],[20824.7,34],[20824.8,43],[20859.2,43],[20895.5,43],[20929.5,63],[20929.5,14],[20996.6,43],[21034,34],[21034.1,35],[21034.2,8],[21075.3,43],[21109.5,34],[21109.6,43],[21162.4,43],[21227.6,34],[21227.8,48],[21227.8,14],[21261.7,43],[21308.9,34],[21309.2,43],[21355.4,43],[21390.9,43],[21434,77],[21472.7,43],[21518.5,34],[21518.7,43],[21555.5,43],[21593.7,43],[21629.7,34],[21629.8,43],[21663.4,43],[21698,43],[21734.8,77],[21767.9,83],[21801.5,43],[21840.7,34],[21840.8,43],[21876.3,83],[21909.9,34],[21910.1,43],[21947.1,43],[21983.8,83],[22020.6,34],[22020.7,43],[22055,83],[22107.6,34],[22107.8,43],[22172.6,83],[22208,34],[22208.1,54],[22208.1,8],[22246.2,83],[22280.1,43],[22319.4,34],[22319.7,43],[22352.8,83],[22409.7,34],[22409.9,35],[22409.9,8],[22486.2,83],[22523.8,34],[22524,83],[22577.4,43],[22619.1,34],[22620.2,83],[22656.4,43],[22692,43],[22725.4,34],[22725.5,43],[22760,43],[22803.6,34],[22803.6,35],[22803.7,8],[22840.5,43],[22875.7,43],[22914.5,34],[22914.7,43],[22958.6,43],[23011.3,77],[23084.5,43],[23119.3,34],[23119.5,43],[23185.9,43],[23221.9,34],[23222.2,62],[23278.6,43],[23333.3,77],[23369.3,43],[23402.6,34],[23402.8,43],[23436.3,43],[23511.4,34],[23511.6,43],[23557.8,43],[23601,43],[23636,34],[23636.2,35],[23636.2,8],[23674.7,43],[23708.7,34],[23709.8,43],[23755.4,43],[23800.4,83],[23835.7,77],[23875.7,83],[23920.4,34],[23920.5,35],[23920.6,8],[23956.8,75],[23956.9,8],[24001,43],[24047,34],[24047.2,83],[24083.5,43],[24173.7,34],[24173.9,83],[24236.4,34],[24236.6,54],[24236.6,8],[24282.1,83],[24325.6,34],[24325.8,43],[24372.6,83],[24405.5,34],[24405.7,43],[24464.3,83],[24500.2,43],[24554.2,34],[24554.3,83],[24588.7,43],[24624.2,34],[24624.3,83],[24681.9,43],[24727.4,34],[24727.5,43],[24788.3,43],[24825.1,77],[24858.3,43],[24931.7,34],[24931.8,43],[24967.4,43],[25015.5,77],[25054,43],[25097.6,43],[25151.3,34],[25151.7,43],[25185.2,43],[25228,34],[25229,63],[25262.3,43],[25295.6,43],[25380.4,34],[25381,43],[25414.4,34],[25414.6,43],[25513.1,34],[25513.5,43],[25554,43],[25587.5,43],[25623.6,34],[25623.8,43],[25658.6,43],[25699.8,43],[25733.3,34],[25733.5,43],[25775.4,83],[25819.7,34],[25820.4,43],[25854.5,43],[25927.8,34],[25927.8,83],[25964.1,83],[26004.8,34],[26005.1,43],[26040.1,43],[26076.4,83],[26162,34],[26162.2,83],[26202.4,34],[26203,62],[26270.5,83],[26319.7,77],[26361.5,83],[26407.5,34],[26407.7,43],[26498.6,83],[26532.2,34],[26532.4,69],[26532.4,14],[26609,34],[26609.2,43],[26645.4,14],[26645.4,69],[26679.5,43],[26714.6,34],[26714.9,43],[26772.1,43],[26805.3,34],[26805.3,43],[26846.1,43],[26880.9,43],[26914.1,34],[26914.2,43],[27022.1,34],[27022.3,43],[27057.8,43],[27092.1,43],[27127.2,34],[27127.5,43],[27161.3,43],[27196.1,62],[27232.1,77],[27266.6,43],[27299.9,43],[27333.3,34],[27333.5,35],[27333.5,8],[27368.9,43],[27401.6,43],[27435.8,34],[27435.9,43],[27468.8,43],[27503.7,34],[27503.9,43],[27538.5,43],[27572.8,43],[27607.4,34],[27607.6,43],[27642.7,43],[27677.8,43],[27714.2,77],[27747,43],[27781.5,83],[27815.5,77],[27849.6,43],[27888.4,83],[27924.6,34],[27924.8,35],[27924.9,8],[27963.3,83],[27997,43],[28031.5,34],[28031.8,43],[28066.7,83],[28099.7,43],[28134.2,34],[28134.5,43],[28169.1,83],[28201.5,62],[28247.2,117],[28285.5,43],[28319.5,77],[28353.9,83],[28388.8,43],[28431.6,117],[28468.8,43],[28501.3,43],[28536.2,34],[28536.4,69],[28536.5,14],[28570.5,43],[28604.8,34],[28605,35],[28605,8],[28642.3,83],[28677.6,43],[28726.6,34],[28726.8,43],[28780.2,43],[28815.5,34],[28815.6,35],[28815.7,8],[28851.1,43],[28885.1,43],[28919.2,34],[28919.4,43],[28963.8,43],[28998.6,43],[29053.3,34],[29053.5,35],[29053.5,8],[29092.1,43],[29125.7,34],[29125.8,43],[29164,43],[29198.9,62],[29235.1,34],[29235.3,43],[29275.2,43],[29310.7,77],[29348.4,43],[29382.1,43],[29475.5,34],[29475.7,43],[29514.2,34],[29514.4,43],[29549.6,43],[29584.5,43],[29628.1,34],[29628.3,43],[29664.5,43],[29728.6,34],[29728.9,43],[29765.5,83],[29832.9,77],[29867,83],[29921.5,34],[29921.7,43],[29966.7,83],[30002.5,34],[30002.7,43],[30036.8,43],[30071.6,83],[30122.8,77],[30156.8,83],[30191.6,62],[30225.7,34],[30225.8,43],[30276.2,75],[30276.2,8],[30364.4,34],[30364.5,69],[30364.6,14],[30406.1,77],[30449.9,83],[30495.3,43],[30530.4,34],[30530.6,83],[30566.3,43],[30611.4,34],[30611.6,43],[30646.5,83],[30740,34],[30740.3,43],[30775.4,43],[30810.2,34],[30810.3,43],[30845.9,35],[30846,8],[30880.5,43],[30917.6,34],[30918.1,43],[30952.4,43],[30986,43],[31020.9,34],[31021.2,35],[31021.2,8],[31054.8,43],[31089.8,43],[31124.8,34],[31125.2,43],[31158.6,43],[31193.5,62],[31228.3,34],[31228.6,43],[31274.5,43],[31308.1,34],[31308.2,35],[31308.2,8],[31343.5,43],[31378.4,43],[31413.4,34],[31413.7,35],[31413.7,8],[31447.4,43],[31482.6,43],[31517.6,34],[31517.8,43],[31553,43],[31587.2,43],[31622.3,34],[31622.6,35],[31622.6,8],[31658,43],[31691,43],[31726,34],[31726.2,35],[31726.2,8],[31761.1,83],[31796.3,43],[31830.8,34],[31831,35],[31831,8],[31866.4,75],[31866.5,8],[31901.6,43],[31936.4,34],[31936.5,43],[31971.6,83],[32006.5,34],[32006.7,43],[32041.1,43],[32074.6,83],[32109.5,34],[32109.7,43],[32143.6,75],[32143.7,8],[32178.9,43],[32212.8,34],[32213,62],[32247.2,83],[32280.5,43],[32315.5,34],[32315.7,43],[32350.8,83],[32385.9,43],[32420.4,34],[32420.6,43],[32453.7,75],[32453.8,8],[32489.1,43],[32522.3,34],[32522.5,35],[32522.5,8],[32557.7,83],[32592.1,43],[32627.1,34],[32627.4,75],[32627.4,8],[32662.5,43],[32697.7,43],[32731.3,34],[32731.5,43],[32766.6,43],[32801.9,34],[32802,43],[32835.6,43],[32870.8,43],[32905.8,34],[32906,43],[32941.1,43],[32976.3,43],[33009.8,34],[33010.1,43],[33045.2,43],[33080.4,43],[33115.9,34],[33116,43],[33151.3,43],[33186.5,43],[33221.4,34],[33221.5,54],[33221.5,8],[33256.2,43],[33289.3,43],[33324.3,34],[33324.5,43],[33359.4,43],[33394.2,43],[33429.1,34],[33429.3,43],[33464.5,43],[33499.6,43],[33534.1,34],[33534.2,43],[33569.5,43],[33602.9,34],[33603,43],[33638.3,43],[33671.5,43],[33706.5,34],[33706.6,43],[33739.9,43],[33773.7,83],[33807.9,34],[33808.1,43],[33843.3,43],[33878.4,83],[33913.4,34],[33913.5,43],[33948.6,43],[33983.7,83],[34017.5,34],[34017.6,43],[34052.2,75],[34052.2,8],[34086.6,43],[34121.6,34],[34121.7,43],[34156.9,83],[34190.2,62],[34229.1,34],[34229.4,14],[34229.4,195],[34231,43],[34254.1,83],[34264.1,43],[34298.8,43],[34333.8,34],[34333.9,75],[34333.9,8],[34369.1,43],[34403.7,34],[34403.8,35],[34403.9,8],[34437.1,75],[34437.1,8],[34470.8,43],[34505.7,34],[34505.9,43],[34539.9,83],[34573,43],[34608.2,34],[34608.4,43],[34642.5,83],[34677.6,43],[34710.7,34],[34710.9,43],[34745.9,43],[34781,43],[34815.5,34],[34815.6,43],[34849.6,43],[34883.4,43],[34917.5,34],[34917.6,43],[34952.9,43],[34987.3,43],[35021.1,34],[35021.3,43],[35056.4,43],[35074.9,102],[35074.9,8],[35148.7,34],[35148.9,43],[35166.4,35],[35166.4,8],[35172.4,43],[35190.3,32],[35190.5,14],[35190.5,271],[35201.3,180]]} \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.txt b/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.txt new file mode 100644 index 00000000000..e151336f421 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-timed-sleep-turn.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25lTip:Visitthe]8;;https://community.openai.com/c/codex/37Codex community forum]8;;(]8;;https://community.openai.com/c/codex/37https://community.openai.com/c/codex/37]8;;).GPT-6-Sol default·~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ scratch[?2026h[?25lGPT-6-Sol medium /model tochangemdim · ~/orca-lanes/sta834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lRun the shell command `sleep25`andthenreplywithexactly:FIRSTDONE[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› Runtheshellcommand`sleep25`andthenreplywithexactly:FIRSTDONEAsk Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ scratch[?2026h[?25l•Working(0s • esc to interrupt)[?25h[?2026l]0;⠸ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l·⠴[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l1[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l2[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l3[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l4[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l · 1 background terminal running · /ps to view · /stop to close[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l5[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l7[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l8[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l⠼[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ scratch]0;⠼ Run sleep command | scratch[?2026h[?25lRun sleep command[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l•9[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l10s • esc to interupt) · 1 background terminal runing · /ps to view · /stop to close[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•1[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l2[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l4[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l5[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l7[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l8[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l9[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l20[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l1[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l2[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l3[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l5[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l6[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l7[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l8[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l9[?25h[?2026l]0;⠸ Run sleep command | scratch[?2026h[?25l•Ransleep 25 └ (no output)[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Run sleep command | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l• FIRSTDONE [?25h[?2026l]0;⠹ Run sleep command | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;Run sleep command | scratch[?2026h[?25lTip:Whengoalsareenabled,use/goaltosetorinspecttheobjectiveforalong-runningtask.[?25h[?2026l[?2026h[?25l 12:06 AM [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json new file mode 100644 index 00000000000..04203db4fbe --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:45:00.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch (no -m/-c) on a fresh CODEX_HOME. The provisional screen reads `model: loading` while Codex installs and starts its shared daemon (`Installing daemon from CLI version 0.157.0`); 0.157 discards typed input during that start. The live header fills in at the end. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt new file mode 100644 index 00000000000..a6aa2c9af2c --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l─────────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/x157fresh/repo ││ permissions: YOLO mode │╰────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/x157fresh/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭────────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/x157fresh/repo ││ permissions: YOLO mode │╰────────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;repo[?2026h[?25h[?2026l[?2026h[?25lTip: Visit the ]8;;https://community.openai.com/c/codex/37Codex community forum]8;; (]8;;https://community.openai.com/c/codex/37https://community.openai.com/c/codex/37]8;;).GPT-6-Astra default · /Users/user1/cr/rounds/x157fresh/repo←agens · ? for shortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ repo]0;repo[?2026h[?25lGPT-6-Astra /model to change⠈⢀⠁⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⡀⠠⠁⢀⠁[?25h[?2026l[?2026h[?25l⠄[?25h[?2026l[?2026h[?25l⠄[?25h[?2026l[?2026h[?25l⠈⢀⠁⠐⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⠄⡀⠈⠠⠁⢀⠁[?25h[?2026l[?2026h[?25l⠈⢀⠁⠐⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⠄⡀⠈⠠⠁⢀[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json new file mode 100644 index 00000000000..ebc2a471a20 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:27.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt new file mode 100644 index 00000000000..14b1ad743e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-h57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json new file mode 100644 index 00000000000..0e97bbe4715 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:31:06.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets model = \"gpt-5.4-mini\", a model missing from 0.157's catalog, so Codex shows its retired-model notice without choices: `GPT-5.4 Mini is no longer available`, `Switch to GPT-6 Luna to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model; the heading comes from the model catalog, not the older `Codex just got an upgrade` wording. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt new file mode 100644 index 00000000000..776b261a2d6 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-m57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 Mini is no longer available  Codex now uses GPT-6 Luna in place of GPT-5.4 Mini. Switch to GPT-6 Luna to continue. enter/esc continue · ctrl+c quit     [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json new file mode 100644 index 00000000000..a81301d2215 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:29:52.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.157.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt new file mode 100644 index 00000000000..4701e63af24 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-u57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.157.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json new file mode 100644 index 00000000000..ea7d9513a9f --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:44:00.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch (no -m/-c) on a fresh CODEX_HOME. Greeting layout: an unboxed `>_ OpenAI Codex (v0.158.0)` header with no model:/directory: labels, starting from a provisional screen whose directory reads `loading`; the live status row under the composer appears last. Braille logo animation frames included. Cut 4.6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt new file mode 100644 index 00000000000..72670b00f4b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingShall we see where this goes?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/x158fresh/repo permissions: YOLO modeShall we see where this goes?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;repo[?2026h[?25h[?2026l[?2026h[?25lGPT-6-Astra default · /Users/user1/cr/rounds/x158fresh/repo←agens · ? for shortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ repo[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ repo[?2026h[?25h[?2026l]0;repo[?2026h[?25l⠈⢀⠁⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⡀⠠⠁⢀⠁[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json new file mode 100644 index 00000000000..896d3474482 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:46.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt new file mode 100644 index 00000000000..ad25e6eda6e --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingBring your unfinished thoughts.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-h58/repo permissions: YOLO modeBring your unfinished thoughts.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json new file mode 100644 index 00000000000..05790b10e95 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:44:30.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets an older model with an available upgrade. After the provisional screen, Codex paints its model announcement (`Meet GPT-6 Sol`, `1. Try new model` / `2. Use existing model`, `enter/esc confirm`), which owns Enter. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt new file mode 100644 index 00000000000..a5c88157d74 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHere for a quick fix or the extended edition?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/x158mig/repo permissions: YOLO modeHere for a quick fix or the extended edition?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Meet GPT-6 Sol  Our latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding. › 1. Try new model  2. Use existing model  enter/esc confirm · ctrl+c quit    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json new file mode 100644 index 00000000000..92137927416 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:31:56.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets model_provider = \"amazon-bedrock\" and model = \"openai.gpt-5.4\" (0.158 keeps the notice for a model missing from its catalog only on the OpenAI and Bedrock providers, and the OpenAI provider asks for a sign-in first, which the rig does not have). Codex shows the notice without choices: `GPT-5.4 on Amazon Bedrock is no longer offered in Codex`, `Switch to GPT-6 Sol on Amazon Bedrock to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt new file mode 100644 index 00000000000..fd9fac82c43 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingBring a question. Bonus points if it’s a weird one.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-m58b/repo permissions: YOLO modeBring a question. Bonus points if it’s a weird one.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 on Amazon Bedrock is no longer offered in Codex  Codex now uses GPT-6 Sol on Amazon Bedrock in place of GPT-5.4 on Amazon Bedrock. Switch to GPT-6 Sol on Amazon Bedrock to continue. enter/esc continue · ctrl+c quit    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json new file mode 100644 index 00000000000..db18029d308 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:10.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.158.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt new file mode 100644 index 00000000000..2c92e766ae2 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHere for a quick fix or the extended edition?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-u58/repo permissions: YOLO modeHere for a quick fix or the extended edition?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.158.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index 3bc936c78a5..9625be02eaf 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -163,26 +163,6 @@ describe('agent prompt submit delay on a ConPTY host', () => { await stalled }) - it('does not send another Enter after cancellation during verification', async () => { - useHostPlatform('win32') - vi.useFakeTimers() - const controller = new AbortController() - const { runtime, handle, writes } = await createPromptRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { - inputKind: 'driving', - signal: controller.signal - }) - const rejected = expect(submission).rejects.toThrow('request_aborted') - - await vi.advanceTimersByTimeAsync(submitDelayFor('review this', 'win32')) - expect(countSubmits(writes)).toBe(1) - controller.abort() - await vi.runAllTimersAsync() - - await rejected - expect(countSubmits(writes)).toBe(1) - }) - it('charges a non-Windows host only the settle window', async () => { useHostPlatform('darwin') vi.useFakeTimers() diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts index 7310c3fff89..07c1345f8da 100644 --- a/src/main/runtime/agent-session-conversation-name-store.test.ts +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -49,7 +50,7 @@ afterEach(async () => { }) async function reservedStore(): Promise { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await store.reserveOwner(reserveRequest()) return store } @@ -60,7 +61,7 @@ describe('AgentSessionRecordStore.setConversationName', () => { await store.setConversationName(SESSION, 'Fix the lease probe') - const reloaded = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reloaded = await openTestAgentSessionRecordStore(directory) expect(reloaded.getRecord(SESSION)?.conversationName).toBe('Fix the lease probe') }) @@ -73,7 +74,7 @@ describe('AgentSessionRecordStore.setConversationName', () => { expect(name).toHaveLength(200) expect(name.startsWith('Fix the ')).toBe(true) // A reload validates every record; an over-long name would be dropped as unreadable. - const reloaded = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reloaded = await openTestAgentSessionRecordStore(directory) expect(reloaded.getRecord(SESSION)?.conversationName).toBe(name) }) @@ -86,13 +87,6 @@ describe('AgentSessionRecordStore.setConversationName', () => { expect(store.getRecord(SESSION)?.conversationName).toBeUndefined() }) - it('does not need the lease: an unfenced rename never contends with the writer', async () => { - const store = await reservedStore() - - // No fence argument exists to pass, and no fence error is raised. - await expect(store.setConversationName(SESSION, 'Fix the lease probe')).resolves.toBeDefined() - }) - it('refuses a session it has no record for', async () => { const store = await reservedStore() diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 6e0bff8098c..9904ca7994f 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,8 +8,11 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { AgentSessionFailedAcquisitionSettlement } from './agent-session-acquisition-failure-settlement' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' const SESSION = 'session-alpha-1' /** The fixture lease's last renewal. */ @@ -27,21 +30,11 @@ async function seed(deathEvidence: AgentSessionDeathEvidence | null): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) + return openTestAgentSessionRecordStore(directory) } beforeEach(async () => { diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts index 51b2fe64daf..6cb9a576078 100644 --- a/src/main/runtime/agent-session-launch-env-admission.test.ts +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -1,8 +1,11 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStoreText +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -45,7 +48,7 @@ afterEach(async () => { describe('agent session launch environment admission', () => { it('does not persist ambient launch variables', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await store.reserveOwner(request()) await store.reserveOwner( request({ @@ -63,15 +66,15 @@ describe('agent session launch environment admission', () => { }) ) - const raw = await readFile(join(directory, 'agent-sessions.json'), 'utf-8') + const raw = await readPersistedTestAgentSessionStoreText(directory) expect(raw).not.toContain('OPENAI_API_KEY') expect(raw).not.toContain('"PATH"') - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) expect(reopened.getRecord(SESSION)).not.toHaveProperty('launchEnv') }) it('rejects an environment that could not be validated before writing', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) const launchEnv = Object.fromEntries( Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) ) @@ -83,7 +86,7 @@ describe('agent session launch environment admission', () => { }) it('rejects an overlong environment key before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await expect( store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts deleted file mode 100644 index 50cf9e6b35c..00000000000 --- a/src/main/runtime/agent-session-launch-env-backfill.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-launch-env' -let directory: string - -function request(overrides: Partial = {}): AgentSessionReserveRequest { - return { - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW, - ...overrides - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('legacy agent session launch environment', () => { - it('durably pins the first environment resolved by a current reservation', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reserveOwner(request()) - await store.reserveOwner( - request({ - expectedFence: 1, - spawnToken: 'spawn-b', - launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000002`, - fingerprint: 'fp-2' - } - }) - ) - - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - expect( - (reopened.getRecord(SESSION) as { launchEnv?: Record } | null)?.launchEnv - ).toBeUndefined() - }) - - it('rejects an environment that could not be reloaded before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - const launchEnv = Object.fromEntries( - Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) - ) - - await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( - 'agent_session_launch_env_invalid' - ) - expect(store.getRecord(SESSION)).toBeNull() - }) - - it('rejects an overlong environment key before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - - await expect( - store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) - ).rejects.toThrow('agent_session_launch_env_invalid') - expect(store.getRecord(SESSION)).toBeNull() - }) -}) diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index b2788f81602..ec636a96eed 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -1,10 +1,13 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStoreText +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const MATCHED = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const @@ -63,7 +66,7 @@ async function establishOwner( async function liveStore(): Promise<{ directory: string; store: AgentSessionRecordStore }> { const directory = await mkdtemp(join(tmpdir(), 'orca-lease-renewal-batch-')) directories.push(directory) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await establishOwner(store, directory, 'a') await establishOwner(store, directory, 'b') return { directory, store } @@ -93,7 +96,7 @@ describe('agent-session lease renewal batch', () => { probe: { outcome: 'pid-absent' }, now: NOW + 5_000 }) - const beforeDisk = await readFile(agentSessionStorePath(directory), 'utf-8') + const beforeDisk = await readPersistedTestAgentSessionStoreText(directory) const beforeFirst = store.getRecord('session-a') await expect( @@ -104,8 +107,8 @@ describe('agent-session lease renewal batch', () => { ).rejects.toThrow('agent_session_checkpoint_stale') expect(store.getRecord('session-a')).toEqual(beforeFirst) - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(beforeDisk) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(await readPersistedTestAgentSessionStoreText(directory)).toBe(beforeDisk) + const reopened = await openTestAgentSessionRecordStore(directory) expect( reopened .listRecords() diff --git a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts index 906921645d4..d5cc9defe28 100644 --- a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts +++ b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts @@ -1,25 +1,23 @@ -import { readFile, writeFile } from 'node:fs/promises' import type { PersistedAgentSessionLease } from '../../shared/agent-session-legacy-handoff-lease' -import { agentSessionStorePath } from './agent-session-record-store-file' - -type StoreFile = { records: Record }> } +import { + editPersistedTestAgentSessionStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' /** Rewrites one lease on disk as an older build left it; this build's types cannot express it. */ export async function writeOlderBuildLease( - directory: string, + stateDirectory: string, sessionId: string, fields: Partial ): Promise { - const path = agentSessionStorePath(directory) - const file: StoreFile = JSON.parse(await readFile(path, 'utf-8')) - file.records[sessionId].lease = { ...file.records[sessionId].lease, ...fields } - await writeFile(path, JSON.stringify(file)) + await editPersistedTestAgentSessionStore(stateDirectory, (persisted) => { + persisted.records[sessionId].lease = { ...persisted.records[sessionId].lease, ...fields } + }) } export async function readPersistedLease( - directory: string, + stateDirectory: string, sessionId: string ): Promise> { - const file: StoreFile = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) - return file.records[sessionId].lease + return (await readPersistedTestAgentSessionStore(stateDirectory)).records[sessionId].lease } diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 5ba7c41cffc..58385e29272 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SESSION = 'session-options' @@ -46,7 +46,7 @@ it('drops provider-rejected persisted options before the next owner proof', asyn }) it('persists resumed provider options atomically with owner proof', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) const reserved = await store.reserveOwner({ sessionId: SESSION, location: { @@ -105,6 +105,6 @@ it('persists resumed provider options atomically with owner proof', async () => ...(options ? { options } : {}) }) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) }) diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index ef0f4b1ab7f..0aa32f74f75 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -28,6 +28,10 @@ import { serializeAgentSessionStoreState } from './agent-session-store-serializa export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const +/** In a host's state directory, beside the journal database: one file adjudicates every session's + * lease. */ +export const AGENT_SESSION_STORE_DIR_NAME = 'agent-sessions' + export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index c614cfa5515..4180af2580b 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -12,8 +12,11 @@ import { readPersistedLease, writeOlderBuildLease } from './agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + testAgentSessionStoreFilePath +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -29,8 +32,7 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -const open = (): Promise => - AgentSessionRecordStore.open({ directory, hostId: 'local' }) +const open = (): Promise => openTestAgentSessionRecordStore(directory) async function persistLiveOwner(): Promise { const store = await open() @@ -89,7 +91,7 @@ describe('a lease the removed terminal handoff wrote', () => { handoffStage: 'old-owner-stopped', handoffOperationId: 'op-handoff' }) - const legacy = await readFile(agentSessionStorePath(directory), 'utf-8') + const legacy = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') const store = await open() expect(store.isSessionUnreadable(SESSION)).toBe(false) @@ -100,7 +102,7 @@ describe('a lease the removed terminal handoff wrote', () => { claimStatus: 'conflicted' }) // An open does not write: another holder of the file mid-restart would read it as a change. - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(legacy) + expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(legacy) // A transaction that changes nothing still writes it. Had the open's revision been taken over // anything but the normalized state, this would read the file as externally changed and @@ -127,8 +129,8 @@ describe('a lease the removed terminal handoff wrote', () => { claimStatus: 'conflicted', handoffStage: 'recovering' }) - const settled = await readFile(agentSessionStorePath(directory), 'utf-8') + const settled = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') await open() - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(settled) + expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(settled) }) }) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 56bcb6debb6..c5d4be5319c 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -10,8 +10,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore, + testAgentSessionStoreFilePath +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -61,12 +65,12 @@ function reserveRequest( } async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) + return openTestAgentSessionRecordStore(directory) } describe('chat tab table', () => { const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - const filePath = () => agentSessionStorePath(directory) + const filePath = () => testAgentSessionStoreFilePath(directory) const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { @@ -74,11 +78,11 @@ describe('chat tab table', () => { await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) // A create that dies before its tab is shown leaves nothing to restore or release. expect(store.getSessionTabId('session-alpha')).toBeNull() - expect((await readFileJson()).sessionTabs).toBeUndefined() + expect((await readPersistedTestAgentSessionStore(directory)).sessionTabs).toBeUndefined() await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') expect(store.getSessionTabId('session-alpha')).toBe('tab-alpha') - const persisted = await readFileJson() + const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.sessionTabs).toEqual([{ tabId: 'tab-alpha', sessionId: 'session-alpha' }]) // Not copied onto the record: the table is the one place the id lives. expect(persisted.records['session-alpha']).not.toHaveProperty('surfaceTabId') diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts new file mode 100644 index 00000000000..74af065cd01 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -0,0 +1,102 @@ +/** + * The one way tests open, seed and read back the durable agent-session record store, so a change + * to where or how the store persists is made here rather than in every test that uses it. Every + * function takes the host's state directory, the one its journal database lives in, and keeps the + * store where the runtime does within it. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + AGENT_SESSION_STORE_DIR_NAME, + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath, + type RetiredAgentSessionClaimKey +} from './agent-session-record-store-file' + +const TEST_HOST_ID = 'local' + +function storeDirectory(stateDirectory: string): string { + return join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME) +} + +/** The store's file, for the tests that assert on or damage its bytes directly. */ +export function testAgentSessionStoreFilePath(stateDirectory: string): string { + return agentSessionStorePath(storeDirectory(stateDirectory)) +} + +/** One committed state of the store, as tests seed it and read it back. */ +export type PersistedTestAgentSessionStore = { + schemaVersion: number + hostId: string + records: Record + operations: Record + retiredClaimKeys: RetiredAgentSessionClaimKey[] + unusableRecords: Record + /** Absent until the store first records a chat tab. */ + sessionTabs?: { tabId: string; sessionId: string }[] +} + +/** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ +export function openTestAgentSessionRecordStore( + stateDirectory: string, + options: { hostId?: string } = {} +): Promise { + return AgentSessionRecordStore.open({ + directory: storeDirectory(stateDirectory), + hostId: options.hostId ?? TEST_HOST_ID + }) +} + +/** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ +export async function seedTestAgentSessionRecordStore( + stateDirectory: string, + seed: { records: readonly PersistedAgentSessionRecord[] } +): Promise { + const persisted: PersistedTestAgentSessionStore = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: TEST_HOST_ID, + records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + } + await mkdir(storeDirectory(stateDirectory), { recursive: true }) + await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted), 'utf-8') +} + +/** Leaves an empty store a newer build wrote: this build reads it but never writes it. */ +export async function seedTestAgentSessionStoreFromNewerBuild( + stateDirectory: string +): Promise { + await mkdir(storeDirectory(stateDirectory), { recursive: true }) + await writeFile( + testAgentSessionStoreFilePath(stateDirectory), + JSON.stringify({ schemaVersion: 99, hostId: TEST_HOST_ID, records: {}, operations: {} }) + ) +} + +/** Everything the store has committed, as text: to assert a value never reached disk, or that an + * action wrote nothing by comparing two reads. */ +export function readPersistedTestAgentSessionStoreText(stateDirectory: string): Promise { + return readFile(testAgentSessionStoreFilePath(stateDirectory), 'utf-8') +} + +export async function readPersistedTestAgentSessionStore( + stateDirectory: string +): Promise { + return JSON.parse(await readPersistedTestAgentSessionStoreText(stateDirectory)) +} + +/** Changes the committed state behind the store's back, as an older build or a damaged disk would. */ +export async function editPersistedTestAgentSessionStore( + stateDirectory: string, + edit: (persisted: PersistedTestAgentSessionStore) => void +): Promise { + const persisted = await readPersistedTestAgentSessionStore(stateDirectory) + edit(persisted) + await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted)) +} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 5ee25d36813..908f843da98 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { @@ -10,12 +10,16 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import type { AgentSessionRecordStore } from './agent-session-record-store' import { - agentSessionStorePath, - AGENT_SESSION_STORE_FILE_NAME -} from './agent-session-record-store-file' + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore, + seedTestAgentSessionStoreFromNewerBuild, + testAgentSessionStoreFilePath +} from './agent-session-record-store-test-harness' +import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -97,7 +101,7 @@ function handleLink( let directory: string async function open(hostId = 'local'): Promise { - return AgentSessionRecordStore.open({ directory, hostId }) + return openTestAgentSessionRecordStore(directory, { hostId }) } /** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ @@ -550,7 +554,7 @@ describe('restart reconciliation', () => { releaseProbe({ outcome: 'pid-absent' }) expect(await reconciliation).toEqual(new Map()) - const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ runtimeFence: 3, claimStatus: 'live', @@ -803,20 +807,20 @@ describe('claim keys, checkpoints, and unreadable rows', () => { it.each([ [ 'invalid checkpoint', - (record: AgentSessionRecord) => + (record: PersistedAgentSessionRecord) => Object.assign(record.lease, { journalCheckpoint: { epoch: 'bad', sequence: 1 } }) ], [ 'missing live proof', - (record: AgentSessionRecord) => Object.assign(record.lease, { provenHandleLinkId: null }) + (record: PersistedAgentSessionRecord) => + Object.assign(record.lease, { provenHandleLinkId: null }) ] ])('quarantines a record with %s', async (_name, corrupt) => { const first = await open() await establishOwner(first) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - corrupt(raw.records['session-alpha']) - await writeFile(filePath, JSON.stringify(raw)) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + corrupt(persisted.records['session-alpha']) + }) expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) @@ -830,7 +834,7 @@ describe('claim keys, checkpoints, and unreadable rows', () => { checkpoint: { epoch: 1, sequence: 1 }, now: NOW }) - await writeFile(join(directory, AGENT_SESSION_STORE_FILE_NAME), '{ truncated') + await writeFile(testAgentSessionStoreFilePath(directory), '{ truncated') const reopened = await open() expect(reopened.recoveredFromBackup).toBe(true) @@ -854,7 +858,8 @@ describe('claim keys, checkpoints, and unreadable rows', () => { ['invalid retired key', [BAD_KEY_STORE]], ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] ])('fails closed when the store is %s', async (_name, copies) => { - const filePath = agentSessionStorePath(directory) + const filePath = testAgentSessionStoreFilePath(directory) + await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, copies[0]) if (copies[1]) { await writeFile(`${filePath}.bak`, copies[1]) @@ -863,11 +868,7 @@ describe('claim keys, checkpoints, and unreadable rows', () => { }) it('refuses to write a store written by a newer schema', async () => { - const filePath = agentSessionStorePath(directory) - await writeFile( - filePath, - JSON.stringify({ schemaVersion: 99, hostId: 'local', records: {}, operations: {} }) - ) + await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() expect(store.readOnly).toBe(true) await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e3f9bcff74b..e5f54265dcb 100644 --- a/src/main/runtime/agent-session-released-reservation-replay.test.ts +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -12,7 +12,8 @@ import { } from '../../shared/agent-session-host-authority' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionLease } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -33,7 +34,7 @@ afterEach(async () => { }) function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) + return openTestAgentSessionRecordStore(directory) } function createRequest( diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index 50d27baa57a..31e473fb5e6 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -72,7 +73,7 @@ async function restart(store: AgentSessionRecordStore) { probe: { outcome: 'exit-observed' }, now: NOW }) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'reservation-unused' }), now: NOW @@ -90,7 +91,7 @@ afterEach(async () => { describe('a Codex thread started in place of one Codex never saved', () => { it('becomes the session identity and survives the next restart', async () => { - const first = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const first = await openTestAgentSessionRecordStore(directory) await prove(first, reserveRequest(), (fence) => codexProviderHandleLink({ threadId: 'thread-unsaved', @@ -127,7 +128,7 @@ describe('a Codex thread started in place of one Codex never saved', () => { }) it('is refused once a resume has proved the conversation Codex saved', async () => { - const first = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const first = await openTestAgentSessionRecordStore(directory) await prove(first, reserveRequest(), (fence) => codexProviderHandleLink({ threadId: 'thread-saved', resumed: false, fence, observedAt: NOW }) ) diff --git a/src/main/runtime/agent-startup-input-assembly-census.test.ts b/src/main/runtime/agent-startup-input-assembly-census.test.ts deleted file mode 100644 index ca05ed03648..00000000000 --- a/src/main/runtime/agent-startup-input-assembly-census.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const REPO_ROOT = join(import.meta.dirname, '../../..') - -/** - * The host modules that turn settings into `buildAgentStartupPlan` inputs. - * - * `buildAgentStartupPlan` was always shared; the input assembly was not, and the hand-rolled - * copies drifted — one dropped `sessionOptionsOverrideAgentArgs` and let configured args defeat a - * picked model. This pins the funnel so a sixth host site cannot re-derive the inputs by hand. - */ -const MIGRATED_HOST_LAUNCH_MODULES = [ - 'src/main/runtime/orca-runtime-create-agent-session.ts', - 'src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts', - 'src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts', - 'src/main/runtime/runtime-worktree-agent-startup.ts' -] - -/** Reading any of these next to a startup plan means the inputs are being assembled by hand. */ -const HAND_ASSEMBLY_MARKERS = [ - 'resolveTuiAgentLaunchArgs(', - 'resolveTuiAgentLaunchEnv(', - 'resolveLocalWindowsAgentStartupShell(' -] - -function read(file: string): string { - return readFileSync(join(REPO_ROOT, file), 'utf8') -} - -describe('host agent-startup input assembly census', () => { - it('routes every migrated host launch site through the shared resolver', () => { - const missing = MIGRATED_HOST_LAUNCH_MODULES.filter( - (file) => !read(file).includes('resolveAgentStartupPlanInputs(') - ) - expect(missing).toEqual([]) - }) - - it('leaves no migrated host site assembling the settings-derived inputs by hand', () => { - const handAssembled = MIGRATED_HOST_LAUNCH_MODULES.filter((file) => { - const source = read(file) - return HAND_ASSEMBLY_MARKERS.some((marker) => source.includes(marker)) - }) - expect(handAssembled).toEqual([]) - }) - - it('detects hand assembly when it is present', () => { - // Positive control: the markers are real names, so an empty result above is a true negative - // rather than a typo that can never match. - const resumeSite = read( - 'src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts' - ) - expect(HAND_ASSEMBLY_MARKERS.some((marker) => resumeSite.includes(marker))).toBe(true) - }) -}) diff --git a/src/main/runtime/agent-terminal-launch-trust-host.test.ts b/src/main/runtime/agent-terminal-launch-host.test.ts similarity index 67% rename from src/main/runtime/agent-terminal-launch-trust-host.test.ts rename to src/main/runtime/agent-terminal-launch-host.test.ts index 949b0c545fc..1925869a4e5 100644 --- a/src/main/runtime/agent-terminal-launch-trust-host.test.ts +++ b/src/main/runtime/agent-terminal-launch-host.test.ts @@ -1,7 +1,5 @@ -// launchAgentTerminal read `store.getRepo(worktree.repoId)?.connectionId` for the trust write — -// host-blind, so the same repo id on two hosts wrote a remote path into the client's agent config -// and the agent on the host never saw the trust (#11163). Every sibling call site already passes -// the resolved `workspace.connectionId`; this was the last one that did not. +// launchAgentTerminal once read the host-blind `store.getRepo(worktree.repoId)`, so the same repo +// id on two hosts built the launch for the wrong one (#11163). import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -18,11 +16,6 @@ const REMOTE_PATH = '/srv/app-feature' type RuntimeInternals = { resolveWorktreeSelector: (selector: string) => Promise buildStartupForAgent: (repo: unknown, agent: unknown, prompt: string) => unknown - markWorkspaceTrustedForAgent: ( - agent: unknown, - connectionId: string | null | undefined, - path: string - ) => Promise createTerminal: (selector: string, opts: unknown) => Promise } @@ -41,24 +34,22 @@ function makeRuntime(repos: readonly Record[], hostId?: string) path: REMOTE_PATH, ...(hostId ? { hostId } : {}) }) - vi.spyOn(internals, 'buildStartupForAgent').mockReturnValue({ + const buildStartup = vi.spyOn(internals, 'buildStartupForAgent').mockReturnValue({ agent: 'codex', startup: { command: 'codex', env: {}, startupCommandDelivery: 'none', telemetry: {} } }) - const markTrusted = vi.fn(async () => {}) - vi.spyOn(internals, 'markWorkspaceTrustedForAgent').mockImplementation(markTrusted) vi.spyOn(internals, 'createTerminal').mockResolvedValue({ id: 'pty-1' }) - return { runtime, markTrusted } + return { runtime, buildStartup } } -describe('launchAgentTerminal trust write', () => { +describe('launchAgentTerminal execution host', () => { beforeEach(() => { vi.restoreAllMocks() }) - it('writes trust on the host the worktree names, not on a rival row', async () => { + it('builds the launch for the host the worktree names, not a rival row', async () => { // Two SSH hosts publish the same repo id; the worktree is on m4air. - const { runtime, markTrusted } = makeRuntime( + const { runtime, buildStartup } = makeRuntime( [ { id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }, { id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' } @@ -69,13 +60,13 @@ describe('launchAgentTerminal trust write', () => { await runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', { agent: 'codex', prompt: 'go' - } as never) + }) - expect(markTrusted).toHaveBeenCalledWith('codex', 'm4air', REMOTE_PATH) + expect(buildStartup.mock.calls[0]?.[0]).toMatchObject({ connectionId: 'm4air' }) }) - it('writes trust locally for a local worktree even when a remote row shares the id', async () => { - const { runtime, markTrusted } = makeRuntime( + it('builds a local launch for a local worktree even when a remote row shares the id', async () => { + const { runtime, buildStartup } = makeRuntime( [ { id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }, { id: 'repo-shared', path: '/home/me/app' } @@ -86,9 +77,10 @@ describe('launchAgentTerminal trust write', () => { await runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', { agent: 'codex', prompt: 'go' - } as never) + }) - expect(markTrusted).toHaveBeenCalledWith('codex', null, REMOTE_PATH) + expect(buildStartup.mock.calls[0]?.[0]).toMatchObject({ path: '/home/me/app' }) + expect(buildStartup.mock.calls[0]?.[0]).not.toHaveProperty('connectionId') }) it('refuses rather than guessing when rival rows disagree and the worktree names no host', async () => { @@ -101,7 +93,7 @@ describe('launchAgentTerminal trust write', () => { runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', { agent: 'codex', prompt: 'go' - } as never) + }) ).rejects.toThrow('worktree_execution_host_unresolved') }) }) diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 7b73571ba79..8a9f9648147 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -22,11 +22,10 @@ function runtimeWithAgentLaunch(): { spawn: ReturnType } { const runtime = new OrcaRuntimeService() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver under test is a protected member; the assertion names only the three internals this stub replaces, each of which is assigned before the create reaches it. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver under test is a protected member; the assertion names only the two internals this stub replaces, each of which is assigned before the create reaches it. const internal = runtime as unknown as { store: { getSettings: () => Record } resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise - markWorkspaceTrustedForAgent: () => Promise } internal.store = { getSettings: () => ({}) } vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ @@ -36,8 +35,6 @@ function runtimeWithAgentLaunch(): { repo: null, folderWorkspace: null }) - // Trust presets touch the real filesystem and are not what this resolver is being asked about. - internal.markWorkspaceTrustedForAgent = async () => undefined const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) runtime.setPtyController({ spawn, diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 1bc8ea272ed..6ef52c56e21 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -234,44 +234,4 @@ describe('Antigravity readiness, decided by captured transcripts', () => { expect(doc).toContain(`${transcript.name}.txt`) } }) - - it('reports how much evidence exists, so a fully skipped run is visible', () => { - const missing = TRANSCRIPTS.filter( - (transcript) => !existsSync(fixturePath(transcript.name)) - ).map((transcript) => `${transcript.name}.txt`) - if (missing.length > 0) { - console.info( - `Antigravity transcripts: ${TRANSCRIPTS.length - missing.length}/${TRANSCRIPTS.length} captured. Missing: ${missing.join(', ')}` - ) - } - expect(missing.length).toBeLessThanOrEqual(TRANSCRIPTS.length) - }) -}) - -describe('scaffold self-check', () => { - // Why these two live here: when a transcript lands and fails, the failure has to mean the - // capture disagreed with the detector — not that the harness or the timeouts are broken. - // Neither case is evidence about Antigravity; both are shapes the current detector already - // decides, used only to prove the plumbing reaches a verdict. - it('reaches a ready verdict through the harness', async () => { - const verdict = await readinessVerdict( - [ - 'Antigravity CLI 1.0.3', - 'user@example.com (Antigravity Business)', - 'Gemini 3.5 Flash (High)', - '~/orca/workspaces/orca/agy-dispatch-issue', - '>' - ].join('\n'), - READY_TIMEOUT_MS - ) - expect(verdict.ready).toBe(true) - }) - - it('reaches a not-ready verdict through the harness', async () => { - const verdict = await readinessVerdict( - 'Do you trust this workspace directory?\nPress t to trust\n', - REFUSAL_TIMEOUT_MS - ) - expect(verdict.ready).toBe(false) - }) }) diff --git a/src/main/runtime/browser-tab-create-publication.test.ts b/src/main/runtime/browser-tab-create-publication.test.ts index c04c2fbdfee..fd5560751b2 100644 --- a/src/main/runtime/browser-tab-create-publication.test.ts +++ b/src/main/runtime/browser-tab-create-publication.test.ts @@ -17,9 +17,7 @@ import { publishCreatedBrowserSessionTab, publishSwitchedBrowserSessionTab, resolveBrowserTabCreateFocus, - type BrowserTabCreatePlacementKind, - type BrowserTabCreatePublicationHost, - type BrowserTabSwitchPlacementKind + type BrowserTabCreatePublicationHost } from './browser-tab-create-publication' const { ipcMainOnMock, waitForTabRegistrationMock } = vi.hoisted(() => ({ @@ -99,12 +97,6 @@ function browserCommandsSource(): string { } describe('publishCreatedBrowserSessionTab', () => { - it('declares a publication rule for every placement kind', () => { - expect(Object.keys(BROWSER_TAB_CREATE_PUBLICATION_RULES).sort()).toEqual( - [...BROWSER_TAB_CREATE_PLACEMENT_KINDS].sort() - ) - }) - // Why: the per-placement cases below read their expectation off this table, so the table itself // needs a literal pin — otherwise dropping a step here would silently rewrite what they assert. it('pins the bookkeeping each placement owns', () => { @@ -150,15 +142,6 @@ describe('publishCreatedBrowserSessionTab', () => { } }) - it('declares a host row source for every placement kind', () => { - for (const placementKind of BROWSER_TAB_CREATE_PLACEMENT_KINDS) { - expect( - ['create-ipc', 'session-notify', 'none'], - `${placementKind} must say where its host tab row comes from` - ).toContain(BROWSER_TAB_CREATE_PUBLICATION_RULES[placementKind].hostRowSource) - } - }) - it.each(BROWSER_TAB_CREATE_PLACEMENT_KINDS)( 'moves a user-created %s tab into the clicked split group when the placement marks focus', (placementKind) => { @@ -312,14 +295,6 @@ describe('browser tab-create activation defaults', () => { expect(browserTabCreateClientPageStartsActive(false)).toBe(false) expect(browserTabCreateClientPageStartsActive(undefined)).toBe(true) }) - - it('agrees with the focus rule for every explicit boolean shipped callers send', () => { - for (const activate of [true, false]) { - expect(browserTabCreateClientPageStartsActive(activate)).toBe( - browserTabCreateTakesFocus(activate) - ) - } - }) }) describe('browser tab-create focus resolution', () => { @@ -549,26 +524,9 @@ describe('browser tab-create placement census', () => { }) }) }) - - it('names every placement kind the command adapter can select', () => { - const source = browserCommandsSource() - const selected = new Set() - for (const [, placementKind] of source.matchAll( - /publishCreatedBrowserSessionTab\(this\.host, \{\s*placementKind: '([a-z]+)'/g - )) { - selected.add(placementKind as BrowserTabCreatePlacementKind) - } - expect([...selected].sort()).toEqual([...BROWSER_TAB_CREATE_PLACEMENT_KINDS].sort()) - }) }) describe('publishSwitchedBrowserSessionTab', () => { - it('declares a publication rule for every switch placement kind', () => { - expect(Object.keys(BROWSER_TAB_SWITCH_PUBLICATION_RULES).sort()).toEqual( - [...BROWSER_TAB_SWITCH_PLACEMENT_KINDS].sort() - ) - }) - // Why: the cases below read their expectation off this table, so the table needs a literal pin. it('pins the bookkeeping each switch placement owns', () => { expect(BROWSER_TAB_SWITCH_PUBLICATION_RULES).toEqual({ @@ -656,14 +614,6 @@ describe('browser tab-switch focus rule', () => { expect(browserTabSwitchTakesFocus(false)).toBe(false) expect(browserTabSwitchTakesFocus(undefined)).toBe(false) }) - - // Why: switch and create are the two ways a tab becomes the session's active tab; if their - // focus gates diverged, the same user intent would move the snapshot on one path only. - it('agrees with the create focus rule', () => { - for (const intent of [true, false, undefined]) { - expect(browserTabSwitchTakesFocus(intent)).toBe(browserTabCreateTakesFocus(intent)) - } - }) }) describe('browser tab-switch placement census', () => { @@ -683,17 +633,6 @@ describe('browser tab-switch placement census', () => { ) }) - it('names every switch placement kind the command adapter can select', () => { - const source = browserCommandsSource() - const selected = new Set() - for (const [, placementKind] of source.matchAll( - /publishSwitchedBrowserSessionTab\(this\.host, \{\s*placementKind: '([a-z]+)'/g - )) { - selected.add(placementKind as BrowserTabSwitchPlacementKind) - } - expect([...selected].sort()).toEqual([...BROWSER_TAB_SWITCH_PLACEMENT_KINDS].sort()) - }) - // Why: the rule-driven cases read their expectation off the table, so each branch also needs // its real bookkeeping observed through browserTabSwitch itself. describe('through browserTabSwitch', () => { diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts new file mode 100644 index 00000000000..becdc9f2b38 --- /dev/null +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -0,0 +1,108 @@ +// A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as +// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, +// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// Against the production runtime, adapter, record store and host, with only the CLI scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-idle-release' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function sessionState(state: 'running' | 'idle'): Record { + return { + type: 'system', + subtype: 'session_state_changed', + state, + uuid: `state-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI reports idle', () => { + it('releases a send whose write ended in doubt, and leaves a pending one alone', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + // Handed to the SDK, then the write's outcome was lost. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + throw new Error('stdin write stalled') + } + const doubted = await send(host, 'first') + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ dispatchState: 'unknown' }) + ) + // Written, never echoed: its dispatch has returned, but nothing has settled it. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + } + const pending = await send(host, 'second') + await vi.waitFor(async () => + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + ) + + child.handlers.onMessage?.(sessionState('running')) + await new Promise((resolve) => setTimeout(resolve, 50)) + // POSITIVE CONTROL: only idle releases it. + expect((await submission(host, doubted))?.recovered).toBeUndefined() + + child.handlers.onMessage?.(sessionState('idle')) + + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + // The write's own doubt is the sharper fact, so it survives the release. + reason: `${DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN}: stdin write stalled` + }) + ) + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, pending))?.recovered).toBeUndefined() + }) +}) diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 183879abe1a..c5b21f9027f 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -75,7 +75,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise> { - const files = await glob(['src/**/*.ts', 'src/**/*.tsx'], { - cwd: REPO_ROOT, - ignore: ['**/node_modules/**', '**/*.test.ts', '**/*.test.tsx', CENSUS_FILE] - }) - const counts: Record = {} - for (const file of files) { - // Counted per file, not merely detected: two call sites in one file mask each other. - const hits = readFileSync(join(REPO_ROOT, file), 'utf8').match(pattern)?.length ?? 0 - if (hits > 0) { - counts[file] = hits - } - } - return counts -} - -// Anchored on the member-call shape rather than the bare name, so the declaration does not count -// itself and prose about the method does not have to be kept out of the file. -const DELIVER_CALL = /\.deliverHydrationSnapshot\(/g -const LIST_CALL = /\.listClientHostedBrowserRows\(/g - -describe('client-hosted row hydration caller census', () => { - it('keeps the hydration delivery to its one runtime caller', async () => { - expect(await countCallSites(DELIVER_CALL)).toEqual({ - 'src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts': 1 - }) - }) - - // The IPC handler is the other half of the same invariant: it is what turns one renderer's - // hydration request into one delivery, so a second production reader would arrive through here. - it('keeps the runtime accessor to its one IPC caller', async () => { - expect(await countCallSites(LIST_CALL)).toEqual({ - 'src/main/ipc/runtime.ts': 1 - }) - }) - - // Why: the two censuses above pass by finding what is already there, so a matcher that had - // stopped matching would read the same as a clean repo. Prove it counts a second caller. - it('counts a second caller rather than reporting the same single site', () => { - const source = ` - const a = runtime.deliverHydrationSnapshot() - const b = other.deliverHydrationSnapshot() - ` - expect(source.match(DELIVER_CALL)?.length).toBe(2) - expect('runtime.listClientHostedBrowserRows()'.match(LIST_CALL)?.length).toBe(1) - }) -}) diff --git a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts index 7aa4562f986..d263c7a0742 100644 --- a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts +++ b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts @@ -80,11 +80,6 @@ describe('ClientHostedPageReconciliationWindow', () => { expect(window.isUnreconciled(DEVICE_A, OPENED_AT + 99)).toBe(true) expect(window.isUnreconciled(DEVICE_A, OPENED_AT + 100)).toBe(false) }) - - // This bound is what stops a host that never returns from holding client-hosted rows open forever. - it('bounds the default hold at 45 seconds', () => { - expect(DEFAULT_CLIENT_HOSTED_RECONCILIATION_WINDOW_MS).toBe(45_000) - }) }) describe('holdFor', () => { @@ -142,10 +137,11 @@ describe('session-tabs projection census', () => { expect(source).toContain('this.clientSessionTabSelections.project(removed,') }) - it('keeps the unreconciled flag out of every other runtime publication site', () => { + it('keeps the hold flags out of every other runtime publication site', () => { const source = readOrcaRuntimeSourceFamily() expect(source).not.toContain('clientHostedPagesUnreconciled') + expect(source).not.toContain('agentSessionsUnverifiable') }) }) diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 0cfcf548017..ea1cfe3b7f0 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import { readRuntimeFixture, replayTranscript, type TranscriptReplayFrame } from './agent-transcript-replay-test-harness' -import { isKnownReadyPromptBody, isKnownReadyPromptPreview } from './terminal-wait-detection' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isKnownReadyPromptSettled, + isQuietReadyScreenBody +} from './terminal-wait-detection' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,7 +24,9 @@ const PLAIN = 'codex-0157-plain-ready' const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' const NO_DAEMON = 'codex-0157-no-daemon-effort-override' -const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON] +// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. +const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' +const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] async function finalFrame( name: string, @@ -41,6 +48,16 @@ function screenShowsLoadingHeader(screenLines: string[]): boolean { return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) } +// Codex's default status row opens with ` ·`; only the live chat paints it. +const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ + +function screenShowsProvisionalStartup(screenLines: string[]): boolean { + return ( + screenShowsLoadingHeader(screenLines) && + !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) + ) +} + describe('Codex 0.157 header readiness from captured bytes', () => { it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( '%s: the line-folded wait text never shows a ready header', @@ -60,14 +77,37 @@ describe('Codex 0.157 header readiness from captured bytes', () => { for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { if (screenShowsLoadingHeader(frame.screenLines)) { sawLoadingHeader = true - expect(isKnownReadyPromptBody('', 'codex', () => frame.screenLines)).toBe(false) + expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) } last = frame } // Presence precondition: a loading frame was actually exercised. expect(sawLoadingHeader).toBe(true) expect(last).not.toBeNull() - expect(isKnownReadyPromptBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) + expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) + } + ) + + // Why: 0.157 discards input typed during its daemon start, behind the provisional header. + it.each(ALL_FIXTURES)( + '%s: never ready while the screen shows the provisional `model: loading` startup screen', + async (name) => { + let sawTextOnlyReadiness = false + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsProvisionalStartup(frame.screenLines)) { + sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( + false + ) + } + } + // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. + if (name === PLAIN || name === FRESH_HOME) { + expect(sawTextOnlyReadiness).toBe(true) + } } ) @@ -78,24 +118,30 @@ describe('Codex 0.157 header readiness from captured bytes', () => { [30, 50], [108, 30], [60, 5] - ])('at %ix%i the screen never takes readiness away from the text rules', (cols, rows) => { + ])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => { it.each(ALL_FIXTURES)('%s', async (name) => { + let settledFrames = 0 for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - if (isKnownReadyPromptPreview(frame.waitText)) { - expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + if (isKnownReadyPromptSettled(frame.waitText)) { + settledFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( true ) } } + // Presence precondition: the text-copy fixtures reach a settled header. + if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { + expect(settledFrames).toBeGreaterThan(0) + } }) }) it('keeps the text rules when there is no live screen', async () => { const { waitText } = await finalFrame(PLAIN, 120, 40) - expect(isKnownReadyPromptBody(waitText, 'codex', () => null)).toBe( + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( isKnownReadyPromptPreview(waitText) ) - expect(isKnownReadyPromptBody(waitText, 'codex', () => null)).toBe(true) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) }) it('does not read a mid-turn composer as ready', () => { @@ -105,7 +151,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { '› Ask Codex to do anything', ' GPT-6-Sol high · ~/repo/app' ] - expect(isKnownReadyPromptBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) + expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) }) it('does not settle when a blocking dialog is painted below the header', () => { @@ -116,7 +162,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { 'Do you trust the contents of this directory?', 'Press enter to continue' ] - expect(isKnownReadyPromptBody('', 'codex', () => screenLines)).toBe(false) + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) }) it('reads only the header box, not chat below it that mentions Codex', () => { @@ -128,25 +174,25 @@ describe('Codex 0.157 header readiness from captured bytes', () => { '╰──────────────────────────────────────────────────────────╯', '› Why does OpenAI Codex print model: loading at startup?' ] - expect(isKnownReadyPromptBody('', 'codex', () => screenLines)).toBe(true) + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) }) - it('leaves a non-codex pane on the text rules even when its screen shows the Codex header', () => { + it('never reads a non-codex screen, even one showing the Codex header', () => { const screenLines = [ '│ >_ OpenAI Codex (v0.157.1) │', '│ model: GPT-6-Sol high /model to change │', '│ directory: ~/repo/app │' ] const readScreenLines = vi.fn(() => screenLines) - expect(isKnownReadyPromptBody('', 'claude', readScreenLines)).toBe(false) + expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) expect(readScreenLines).not.toHaveBeenCalled() - expect(isKnownReadyPromptBody('', 'codex', readScreenLines)).toBe(true) + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) }) describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { it.each(ALL_FIXTURES)('%s', async (name) => { const { screenLines, waitText } = await finalFrame(name, 80, 24) - expect(isKnownReadyPromptBody(waitText, 'codex', () => screenLines)).toBe( + expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( isKnownReadyPromptPreview(waitText) ) }) @@ -167,18 +213,104 @@ describe('Codex 0.157 header readiness from captured bytes', () => { '%s: a tui-idle wait settles from the live screen', async (name) => { const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) - // Why 5s: the poll re-reads the grid every 2s once the queued emulator write lands. + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) }, 15_000 ) + it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { + const data = readRuntimeFixture(FRESH_HOME) + const install = data.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = data.slice(0, data.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: provisional, + size: { cols: 120, rows: 40 } + }) + // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the + // runtime asks for a visible-screen read instead of its own text copy. + it('does not settle from a visible-screen read of the provisional screen', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '', + size: { cols: 120, rows: 40 } + }) + const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.0) │', + '│ model: loading /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' ? for shortcuts' + ], + truncated: false, + nextCursor: null, + source: 'screen' + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + // Presence precondition: the visible-screen probe actually ran. + expect(readVisibleScreen).toHaveBeenCalled() + }, 15_000) + + // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. + it('settles a restored Codex pane from its header, as main did', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '' + }) + runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { + text: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: gpt-6-sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' gpt-6-sol high · ~/repo/app' + ].join('\r\n') + }) + // Why no screen: the visible-screen probe must not be what settles the wait. + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [], + truncated: false, + nextCursor: null, + source: 'screen-unavailable' + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 15_000) + it('keeps timing out on the garbled 80x24 default grid, as before', async () => { const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) ).rejects.toThrow(/timeout/) }, 15_000) }) diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 50052365905..223eef2d858 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -12,6 +12,7 @@ import { isCodexComposerReadyScreen } from './codex-terminal-readiness' import { detectTerminalWaitBlockedReason, isKnownReadyPromptBody, + isKnownReadyPromptPreview, isQuietReadyScreenBody } from './terminal-wait-detection' import { @@ -191,7 +192,8 @@ describe('Codex composer ready screen, frame by frame', () => { const verdict = evaluateTuiIdle({ record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText, 'codex', () => screenLines), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', @@ -269,7 +271,93 @@ describe('the quiet lane over recorded chunk timing (default animations)', () => ) }) -describe('never less ready than origin/main', () => { +describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( + '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + vi.useFakeTimers() + const verdictAt = (index: number, now: number) => { + vi.setSystemTime(now) + const { waitText, screenLines } = frames[index]! + return evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + } + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + let headerMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isKnownReadyPromptPreview(frames[index]!.waitText)) { + headerMidTurn += 1 + } + const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) + expect({ index, settles }).toEqual({ index, settles: false }) + } + // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. + expect(headerMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + expect(verdictAt(last, times[last]!).kind).toBe('pending') + expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') + }, + 120_000 + ) + + const header = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] + + it('reads the header as tier-1 evidence only for a pane with no output clock', () => { + const waitText = header.join('\n') + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) + expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) + }) + + // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. + const NOW = 60_000 + it.each([ + [null, 'ready-strong'], + [0, 'ready-strong'], + [NOW - 1_000, 'pending'] + ] as const)( + 'a codex pane whose lastOutputAt is %s reads its header as %s', + (lastOutputAt, kind) => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const waitText = header.join('\n') + const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } + const verdict = evaluateTuiIdle({ + record, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), + agent: 'codex', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + expect(verdict.kind).toBe(kind) + } + ) +}) + +describe('reading the live screen never removes quiet-lane readiness', () => { const records = [ { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, @@ -287,7 +375,7 @@ describe('never less ready than origin/main', () => { const base = { readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines), + isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), agent, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS @@ -296,7 +384,7 @@ describe('never less ready than origin/main', () => { const before = evaluateTuiIdle({ ...base, record, - // Why a withheld screen: that leaves exactly main's Muse lane, the only widening. + // Why a withheld screen: isolates what the live screen adds to the quiet lane. readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(frame.waitText, agent, () => null) }) diff --git a/src/main/runtime/codex-startup-dialog-transcripts.test.ts b/src/main/runtime/codex-startup-dialog-transcripts.test.ts new file mode 100644 index 00000000000..ac636eb8a05 --- /dev/null +++ b/src/main/runtime/codex-startup-dialog-transcripts.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isQuietReadyScreenBody +} from './terminal-wait-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +type StartupDialog = { + name: string + reason: RuntimeTerminalWaitBlockedReason + heading: string + keyRow: string +} + +// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). +// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. +const DIALOGS: StartupDialog[] = [ + { + // Recorded separately on 0.157.1 (STA-8834). + name: 'codex-0-157-1-update-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0158-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0158-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0157-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer available', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer offered', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-announcement-dialog', + reason: 'codex-model-migration-prompt', + heading: 'Try new model', + keyRow: 'enter/esc confirm · ctrl+c quit' + } +] +const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) +const LIVE_CHAT_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override', + 'codex-0157-fresh-home-daemon-install', + 'codex-0158-fresh-home-greeting' +] + +function screenText(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n') +} + +async function lastFrame(data: string): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(data, 120, 40)) { + last = frame + } + return last +} + +// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup +// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. +function answered(name: string): string { + const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') + return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) +} + +describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { + it.each(DIALOGS)( + '$name: reports $reason from its key row on, and never reads ready while it is up', + async ({ name, reason, heading, keyRow }) => { + let headingFrames = 0 + let keyRowFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenText(frame) + if (screen.includes(heading)) { + headingFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( + false + ) + } + if (keyRowFrames > 0 || screen.includes(keyRow)) { + keyRowFrames += 1 + expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) + } + } + // Presence precondition: the dialog and its key row were painted, not just parsed. + expect(headingFrames).toBeGreaterThan(0) + expect(keyRowFrames).toBeGreaterThan(0) + } + ) + + it.each(DIALOGS_0158)( + '$name: stops reporting once Codex repaints its chat after it', + async ({ name, heading }) => { + const last = await lastFrame(answered(name)) + // Presence precondition: the answered dialog is still in the text copy. + expect(last?.waitText).toContain(heading.replace(' ·', '')) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() + } + ) + + it.each(DIALOGS)( + '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', + async ({ name, reason }) => { + // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. + const dialog = readRuntimeFixture(name) + const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) + expect( + isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) + ).toBe(false) + } + ) + + it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { + const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. + const chat = [ + '› is there an update available, and do my hooks need review?', + '✨ Update available! 0.158.0 -> 0.159.0', + 'Run npm install -g @openai/codex to update.', + ' gpt-6-astra default · ~/repo', + ' ← for agents · ? for shortcuts' + ].join('\n') + expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() + }) + + it('does not name a mid-session Codex popup a hooks review', () => { + // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. + const popup = [ + ' Use this reset?', + ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', + '› 2. No, go back Choose a different reset', + ' enter confirm · esc back' + ].join('\n') + expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') + }) + + describe('through the runtime', () => { + it.each(DIALOGS)( + '$name: stops a tui-idle wait as $reason instead of typing into it', + async ({ name, reason }) => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) + }) +}) diff --git a/src/main/runtime/codex-terminal-readiness.ts b/src/main/runtime/codex-terminal-readiness.ts index 3b15c3359df..7ce60fa9933 100644 --- a/src/main/runtime/codex-terminal-readiness.ts +++ b/src/main/runtime/codex-terminal-readiness.ts @@ -23,7 +23,7 @@ function findCodexHeader(screen: string): { index: number; text: string } | null return { index, text } } -/** Tier 1: the 0.150-0.157 header, which only a grid reassembles (see isKnownReadyPromptBody). */ +/** The 0.150-0.157 header, which only a grid reassembles (see isCodexScreenHeaderReady). */ export function findCodexScreenReadyPromptIndex(screen: string): number | null { const header = findCodexHeader(screen) return header !== null && @@ -34,6 +34,27 @@ export function findCodexScreenReadyPromptIndex(screen: string): number | null { : null } +// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live +// screen shows no header then, while the text copy keeps the provisional one until the live chat +// paints its footer after it (a later model repaint rewrites only the value, never the label). +// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`); +// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first. +export function isCodexProvisionalStartupText(normalized: string): boolean { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return false + } + const loading = /model:\s+loading/.exec(normalized.slice(headerIndex)) + return loading !== null && !normalized.includes('·', headerIndex + loading.index) +} + +// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the +// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered. +export function findCodexHeaderIndex(normalized: string): number | null { + const index = normalized.lastIndexOf('openai codex (v') + return index === -1 ? null : index +} + /** * Tier 1b, codex panes only: the empty composer with no busy status row just above it and no * header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts index e051ca7ae69..7272d01ed7b 100644 --- a/src/main/runtime/fit-override-integration.test.ts +++ b/src/main/runtime/fit-override-integration.test.ts @@ -31,7 +31,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { }) vi.mock('../ipc/registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../git/repo', async (importOriginal) => { diff --git a/src/main/runtime/managed-worktree-create-execution-host.test.ts b/src/main/runtime/managed-worktree-create-execution-host.test.ts index af1a73dc9cc..d33f8525741 100644 --- a/src/main/runtime/managed-worktree-create-execution-host.test.ts +++ b/src/main/runtime/managed-worktree-create-execution-host.test.ts @@ -1,7 +1,6 @@ // createManagedWorktree used to pick remote-vs-local from the raw `connectionId` field, so a repo // stamped only `executionHostId: 'ssh:*'` ran `git worktree add` on the client against a remote -// path — and the folder branch, which returns before that check, wrote agent trust locally for a -// remote workspace. Both are the #11163 shape: read the execution host, never one spelling of it. +// path. That is the #11163 shape: read the execution host, never one spelling of it. import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -21,16 +20,6 @@ vi.mock('./runtime-local-worktree-create', () => ({ createRuntimeLocalManagedWorktree: createRuntimeLocalManagedWorktreeMock })) -const trustMocks = vi.hoisted(() => ({ - local: vi.fn(async () => {}), - remote: vi.fn(async () => {}) -})) -vi.mock('./runtime-worktree-agent-startup', async (importOriginal) => ({ - ...(await importOriginal>()), - markLocalWorktreeTrusted: trustMocks.local, - markRemoteWorktreeTrusted: trustMocks.remote -})) - import { OrcaRuntimeService } from './orca-runtime' const TARGET_ID = 'remote-1' @@ -77,8 +66,6 @@ describe('createManagedWorktree execution-host routing', () => { createRuntimeLocalManagedWorktreeMock.mockRejectedValue( new Error('local_worktree_create_ran_for_remote_repo') ) - trustMocks.local.mockClear() - trustMocks.remote.mockClear() }) it('creates on the SSH host for a repo stamped executionHostId only', async () => { @@ -115,41 +102,4 @@ describe('createManagedWorktree execution-host routing', () => { expect.anything() ) }) - - it('marks a folder workspace trusted on its SSH host, not on the client', async () => { - const { runtime } = makeRuntime({ - id: 'repo-folder', - path: REMOTE_PATH, - kind: 'folder', - connectionId: TARGET_ID, - executionHostId: `ssh:${TARGET_ID}` - }) - - await runtime.createManagedWorktree({ repoSelector: 'repo-folder', name: 'notes' } as never) - - const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps - await deps.markTrusted('codex', '/srv/app') - - expect(trustMocks.remote).toHaveBeenCalledWith('codex', TARGET_ID, '/srv/app') - expect(trustMocks.local).not.toHaveBeenCalled() - }) - - it('keeps a local folder workspace trusted on the client', async () => { - const { runtime } = makeRuntime({ - id: 'repo-folder-local', - path: '/Users/me/notes', - kind: 'folder' - }) - - await runtime.createManagedWorktree({ - repoSelector: 'repo-folder-local', - name: 'notes' - } as never) - - const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps - await deps.markTrusted('codex', '/Users/me/notes') - - expect(trustMocks.local).toHaveBeenCalledWith('codex', '/Users/me/notes') - expect(trustMocks.remote).not.toHaveBeenCalled() - }) }) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 7cf24f40534..c26ede60b43 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -19,7 +19,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } }) vi.mock('../ipc/registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../git/repo', async (importOriginal) => { const actual = (await importOriginal()) as Record diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 06f99c594fb..841c22ae925 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -161,6 +161,9 @@ describe('mobile RPC allowlist', () => { 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', 'agentSession.close', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', diff --git a/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts index 7fe33b66ee1..1984c8bd488 100644 --- a/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts +++ b/src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts @@ -20,7 +20,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { }) vi.mock('../ipc/registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../git/repo', async (importOriginal) => { diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index b3be748d5f0..c57a52f5f17 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -30,7 +30,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { }) vi.mock('../ipc/registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../git/repo', async (importOriginal) => { diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 28ee6cc0a92..1629c529921 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -12,9 +12,7 @@ import type { } from './runtime-worktree-agent-startup' import { buildWorktreeStartupForAgent, - buildWorktreeStartupForDraft, - markLocalWorktreeTrusted, - markRemoteWorktreeTrusted + buildWorktreeStartupForDraft } from './runtime-worktree-agent-startup' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' import type { Worktree } from '../../shared/worktree/types' @@ -164,33 +162,6 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM }) } - protected async markLocalWorkspaceTrustedForAgent( - agent: TuiAgent, - workspacePath: string - ): Promise { - await markLocalWorktreeTrusted(agent, workspacePath) - } - - protected async markWorkspaceTrustedForAgent( - agent: TuiAgent, - connectionId: string | null | undefined, - workspacePath: string - ): Promise { - if (connectionId) { - await this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath) - return - } - await this.markLocalWorkspaceTrustedForAgent(agent, workspacePath) - } - - protected async markRemoteWorkspaceTrustedForAgent( - agent: TuiAgent, - connectionId: string, - workspacePath: string - ): Promise { - await markRemoteWorktreeTrusted(agent, connectionId, workspacePath) - } - protected recordCreatedWorktreeLineage( worktree: Pick, lineageResolution: WorktreeLineageResolution diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 8d7d6fa6812..37b329670d2 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -57,16 +57,12 @@ function createRuntime(provider?: { ) const internal = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: ReturnType - markLocalWorkspaceTrustedForAgent: ReturnType - markRemoteWorkspaceTrustedForAgent: ReturnType } internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ id: 'worktree-1', path: '/tmp/worktree-1', connectionId: null })) - internal.markLocalWorkspaceTrustedForAgent = vi.fn() - internal.markRemoteWorkspaceTrustedForAgent = vi.fn() return runtime } @@ -84,7 +80,6 @@ function installRemoteReclaimHarness( connectionId: 'ssh-1' })), executionOwnerSupportsAgentSessionOperation: vi.fn(async () => true), - markWorkspaceTrustedForAgent: vi.fn(async () => {}), adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => { handleByPtyId.set(ptyId, handle) }), @@ -149,16 +144,13 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).toHaveBeenCalledOnce() }) - it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => { + it('selects legacy before spawn or ledger state for an old daemon', async () => { const provider = { supportsAgentSessionClaims: vi.fn(() => false), supportsAgentSessionCreateOperations: vi.fn(() => false) } const runtime = createRuntime(provider) const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) - const internal = runtime as unknown as { - markLocalWorkspaceTrustedForAgent: ReturnType - } const id = operationId() await expect(runtime.createAgentSession(request(id))).rejects.toThrow( @@ -174,7 +166,6 @@ describe('agent-session create operation ledger', () => { ).rejects.toThrow('agent_session_legacy_required') expect(createTerminal).not.toHaveBeenCalled() - expect(internal.markLocalWorkspaceTrustedForAgent).not.toHaveBeenCalled() provider.supportsAgentSessionCreateOperations.mockReturnValue(true) await expect(runtime.createAgentSession(request(id))).resolves.toMatchObject({ @@ -270,7 +261,6 @@ describe('agent-session create operation ledger', () => { const runtime = createRuntime() const internal = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: ReturnType - markRemoteWorkspaceTrustedForAgent: ReturnType } internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({ id: 'worktree-1', @@ -293,7 +283,6 @@ describe('agent-session create operation ledger', () => { ).rejects.toThrow('agent_session_legacy_required') expect(createTerminal).not.toHaveBeenCalled() - expect(internal.markRemoteWorkspaceTrustedForAgent).not.toHaveBeenCalled() }) it('replays the same completed operation without spawning again', async () => { diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 68101c80960..055e64c4622 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -40,7 +40,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW } for (const id of ids) { const projected = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, id), + this.withSessionTabsHolds(snapshot, id), id, activeTabId ) @@ -52,7 +52,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW } else if (clientNavigationId) { // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. callerSnapshot = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, clientNavigationId), + this.withSessionTabsHolds(snapshot, clientNavigationId), clientNavigationId, activeTabId ) diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index 662ea04c3da..5064944215b 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -17,6 +17,7 @@ import { } from '../../shared/automation-list-scope' import { OrchestrationDb } from './orchestration/db' import { join } from 'node:path' +import { existsSync } from 'node:fs' import { getAppEnvironment } from '../../shared/app-environment' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' @@ -152,14 +153,24 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg // to inject an in-memory DB without touching the filesystem. getOrchestrationDb(): OrchestrationDb { if (!this._orchestrationDb) { - const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') - this._orchestrationDb = new OrchestrationDb(dbPath) + this._orchestrationDb = new OrchestrationDb(this.orchestrationDbPath()) this.ensureOrchestrationFederationRelay() this.scheduleRestoredMessageRepoints() } return this._orchestrationDb } + /** The database, opened only if it already exists: a profile without one has no mail to redrive. */ + getExistingOrchestrationDb(): OrchestrationDb | null { + return this._orchestrationDb || existsSync(this.orchestrationDbPath()) + ? this.getOrchestrationDb() + : null + } + + private orchestrationDbPath(): string { + return join(getAppEnvironment().getPath('userData'), 'orchestration.db') + } + setOrchestrationDb(db: OrchestrationDb): void { this.orchestrationFederation.resetForDatabaseChange() this.mailPointerRepointScheduler.clear() diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 73d0e44499f..a4d01045475 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -8,6 +8,7 @@ import type { } from '../../shared/runtime-types' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import { holdAgentSessionInventory } from './structured-agent-session-inventory-hold' import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, @@ -294,26 +295,24 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu /** * Answers one client's session-tabs question: whether this runtime has taken back *that* client's - * client-hosted pages yet, then that client's own tab selection. + * client-hosted pages yet and can say which chats exist, then that client's own tab selection. * - * The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a - * frame built for one client can never carry another client's answer. + * The holds are decided here and nowhere else, and each is set or cleared rather than only set, + * so a frame built for one client can never carry another client's, or an earlier, answer. */ protected projectMobileSessionTabsForClient( result: RuntimeMobileSessionTabsResult, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { return this.clientSessionTabSelections.project( - this.withClientHostedPagesHold(result, clientNavigationId), + this.withSessionTabsHolds(result, clientNavigationId), clientNavigationId ) } - protected withClientHostedPagesHold( - result: RuntimeMobileSessionTabsResult, - clientNavigationId: string | undefined - ): RuntimeMobileSessionTabsResult { - return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now()) + protected withSessionTabsHolds(result: RuntimeMobileSessionTabsResult, navigationId?: string) { + const held = this.clientHostedPageReconciliation.holdFor(result, navigationId, Date.now()) + return holdAgentSessionInventory(held, this.structuredAgentSessionInventoryUnverifiable) } protected async refreshMobileSessionPtyRecords( diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 61d80e60b09..36e4d3f41a1 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -170,7 +170,6 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe if (!startup) { throw new Error('agent_session_identity_required') } - await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) if (caller.signal?.aborted) { throw new Error('client_disconnected') } diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index 5d2cea95b5e..a62527e7071 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -28,8 +28,6 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC return createRuntimeRemoteManagedWorktree(repo, args, { store: this.store, canSpawn: () => Boolean(this.ptyController?.spawn), - markTrusted: (agent, connectionId, path) => - this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, path), createTerminal: (selector, options) => this.createTerminal(selector, options), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index c293d65460f..d0885a1dfe1 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -10,7 +10,7 @@ import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { @@ -88,12 +88,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork // an `executionHostId: 'ssh:*'`-only repo down the local path, which runs `git worktree add` on // the client against a remote path. const createRoute = resolveWorktreeCreateRoute(repo) - // `null` on a `runtime:` host is deliberate: its nested target is addressable only inside that - // environment, so the trust write must not go to a same-named target in this client's table. - const sshConnectionId = createRoute.kind === 'ssh' ? createRoute.connectionId : null if (isFolderRepo(repo)) { - // A folder workspace is a registration, not a filesystem create, so it is host-agnostic — - // except for the agent trust write, which must land on the host that will run the agent. + // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. return createRuntimeFolderWorktree({ request: args, repo, @@ -105,8 +101,6 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork store: this.store, ptySpawnAvailable: Boolean(this.ptyController?.spawn), createTerminal: (selector, options) => this.createTerminal(selector, options), - markTrusted: (agent, path) => - this.markWorkspaceTrustedForAgent(agent, sshConnectionId, path), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), @@ -217,7 +211,11 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork // to authorize paths. Without invalidating it here, CLI-created worktrees // are not recognized and all git operations fail with "Access denied: // unknown repository or worktree path". - invalidateAuthorizedRootsCache() + // Scoped to this repo: the global form dirties every owner, so the next + // authorization-requiring IPC relists EVERY registered repo — ~58 `git worktree + // list` spawns, ~10s of git wall-clock through an admission budget of 4, to + // rediscover roots only this repo changed. + invalidateAuthorizedRootsCacheForRepo(this.store, repo) this.notifyWorktreesChanged(repo.id) const { @@ -243,7 +241,6 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork warning, ports: { canSpawn: Boolean(this.ptyController?.spawn), - markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path), createTerminal: (selector, options) => this.createTerminal(selector, options), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index a29b3b9d0e0..20172740460 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -20,7 +20,8 @@ export { addClaudeTeammateModeInProcess } from '../../shared/claude-agent-teams-tmux-compat' export { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' -export { getTerminalViewColorQueryReplyColors } from './terminal-view-attribute-store' +export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view-attribute-store' +export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' export type { RuntimePtyController } from './runtime-pty-controller-contract' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' export type { IpcMainEvent } from 'electron' diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index e95431dd51c..242e0f7f454 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -14,6 +14,12 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (opts.startupAgent && worktreeSelector === undefined) { throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`) } + const callerColors = dependencies.normalizeColorQueryReplyColors(opts.terminalColorQueryReplies) + // Why: only a paired client sends colours here; a client that predates + // terminal.setViewerColors reports its theme to a headless host only this way. + if (callerColors) { + dependencies.setPairedViewerColors(callerColors) + } const presentation = dependencies.resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() @@ -116,9 +122,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate tabId, agentTeamsPlan?.env ) - const terminalColorQueryReplies = - launchOpts.terminalColorQueryReplies ?? - dependencies.getTerminalViewColorQueryReplyColors() + const terminalColorQueryReplies = dependencies.getTerminalViewerColors() if (launchOpts.signal?.aborted) { throw new Error('client_disconnected') } diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index a15be97f8a4..7f968a6ba7d 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -144,7 +144,6 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim if (!startup) { throw new Error('agent_session_identity_required') } - await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) if (_caller.signal?.aborted) { throw new Error('client_disconnected') } diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 5eb0bffb8e8..2e52e6f0748 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -16,9 +16,9 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { resolveTerminalOrchestrationCliCommand, + runtimeOrchestrationCliCommand, type OrchestrationCliCommand } from './orchestration/cli-command' -import { getAppEnvironment } from '../../shared/app-environment' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' @@ -264,8 +264,7 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim connectionId: pty.connectionId, isWsl: pty.isWsl, worktreeId: pty.worktreeId, - // Dev builds run the CLI as `orca-dev`; a packaged app must not advertise it. - runtimeCliCommand: getAppEnvironment().isPackaged() ? undefined : 'orca-dev', + runtimeCliCommand: runtimeOrchestrationCliCommand(), projectRuntime: this.store ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) : undefined diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index e26301e379a..5906ee91866 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -7,6 +7,13 @@ import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerIdentities } from './structured-worker-identity' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' +import { + handleLessCoordinatorSessionId, + structuredSessionAddressTarget, + structuredSessionMailTarget, + structuredSessionIdleEdgeMailboxes, + structuredWorkerMailSessionId +} from './orchestration/structured-session-mail-target' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -187,6 +194,32 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM this.orchestrationStructuredMailboxPointerDelivery.onJournalActivity(sessionId) } + /** + * Every structured session's status change reaches here. At its idle edge, retry what is parked + * on it and re-derive the mailboxes it owns, so mail it could not take earlier (mid-turn, closed) + * is pointed again. Workers and chats alike: this is not per-dispatch. + */ + onStructuredSessionStatusForMail(summary: { + sessionId: string + status: 'working' | 'attention' | 'idle' | null + }): void { + if (summary.status === 'working' || summary.status === 'attention') { + return + } + // Logged, never thrown: the same status callback goes on to the first-turn workspace rename. + try { + this.notifyStructuredSessionJournalActivity(summary.sessionId) + const openDb = () => this.getExistingOrchestrationDb() + const deliver = (mailbox: string) => this.deliverPendingMessagesForHandle(mailbox) + structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver) + } catch (error) { + console.warn('[orchestration] structured session mail redrive failed', { + sessionId: summary.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) @@ -205,6 +238,10 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (mailboxHandle.startsWith('run:')) { return this.resolveStructuredCoordinatorMailboxTarget(mailboxHandle.slice('run:'.length)) } + const addressed = structuredSessionAddressTarget(mailboxHandle, this._orchestrationDb) + if (addressed !== undefined) { + return addressed + } if (!mailboxHandle.startsWith('dispatch:')) { return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) } @@ -213,8 +250,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (!assignee) { return null } - const identity = resolveStructuredWorkerAuthority(assignee, this._orchestrationDb)?.identity - return identity ? { sessionId: identity.sessionId, dispatchId } : null + const sessionId = this.liveStructuredWorkerSessionId(assignee) + return sessionId ? { sessionId, dispatchId } : null } /** @@ -228,12 +265,17 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM protected resolveStructuredCoordinatorMailboxTarget( runId: string ): StructuredPointerTarget | null { - const coordinator = this._orchestrationDb?.getRun?.(runId)?.coordinator_handle + const run = this._orchestrationDb?.getRun?.(runId) + const sessionId = run ? handleLessCoordinatorSessionId(run) : null + if (sessionId) { + return structuredSessionMailTarget(sessionId, this._orchestrationDb) + } + const coordinator = run?.coordinator_handle if (!coordinator) { return null } - const identity = resolveStructuredWorkerAuthority(coordinator, this._orchestrationDb)?.identity - return identity ? { sessionId: identity.sessionId, dispatchId: null } : null + const workerSessionId = this.liveStructuredWorkerSessionId(coordinator) + return workerSessionId ? { sessionId: workerSessionId, dispatchId: null } : null } /** @@ -255,11 +297,19 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM // Answers null for anything that is not a live structured worker of THIS runtime, so `run:` // and PTY handles fall through to the PTY lane exactly as before. const identity = resolveStructuredWorkerAuthority(handle, db)?.identity - if (!identity) { + const sessionId = identity ? structuredWorkerMailSessionId(identity.sessionId) : null + if (!identity || !sessionId) { return null } const dispatchId = db?.findActiveDispatchForAssignee?.(handle, identity.paneKey)?.id ?? null - return { sessionId: identity.sessionId, dispatchId } + return { sessionId, dispatchId } + } + + /** The live session behind a structured worker handle of this runtime; see + * `structuredWorkerMailSessionId`. */ + private liveStructuredWorkerSessionId(handle: string): string | null { + const identity = resolveStructuredWorkerAuthority(handle, this._orchestrationDb)?.identity + return identity ? structuredWorkerMailSessionId(identity.sessionId) : null } protected scheduleRestoredMessageRepoints(): void { diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index ce2cf31b9ce..c0d4c596ffa 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,9 +19,11 @@ import { } from './structured-agent-account-home' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' +import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( @@ -93,13 +95,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) ) } - return this.resolveStructuredAgentSessionIntent(input, ({ workspacePath, launchEnv }) => - resolveStructuredCodexAccountHomePath({ + return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv }) => { + await applyStructuredCodexWorkspaceTrust({ + workspacePath: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path, launchEnv, - resolveLaunchHome: this.prepareCodexStructuredLaunchFn, - workspacePath + settings: this.requireStore().getSettings() }) - ) + return resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: this.prepareCodexStructuredLaunchFn + }) + }) } /** @@ -130,8 +136,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR // must not sync homes, start bridges, or clear an account selection. path: await resolveStructuredCodexAccountHomePath({ launchEnv, - resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn, - workspacePath: '' + resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn }) } } @@ -145,7 +150,6 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR resumeFrom?: { providerSessionId: string } }, resolveAccountHomePath: (context: { - workspacePath: string launchEnv: NodeJS.ProcessEnv location: { executionHostId: string @@ -168,7 +172,6 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR input.agent ) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) - const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, @@ -179,11 +182,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR if (committedReplay) { return committedReplay } - const selectedAccountHomePath = await resolveAccountHomePath({ - workspacePath, - launchEnv, - location - }) + const selectedAccountHomePath = await resolveAccountHomePath({ launchEnv, location }) // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under @@ -237,10 +236,19 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR restoreStructuredAgentSessionTabs(): Promise { this.structuredAgentSessionTabRestorePromise ??= - this.restoreStructuredAgentSessionTabsOnce().catch((error) => { - this.structuredAgentSessionTabRestorePromise = null - throw error - }) + this.restoreStructuredAgentSessionTabsOnce().then( + () => { + // Only a host's answer is final: without one, the next caller restores again, so a journal + // that opens later republishes the chats. + if (this.structuredAgentSessionInventoryUnverifiable) { + this.structuredAgentSessionTabRestorePromise = null + } + }, + (error) => { + this.structuredAgentSessionTabRestorePromise = null + throw error + } + ) return this.structuredAgentSessionTabRestorePromise } @@ -258,7 +266,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return } // Durable agent records must exist before daemon inventory can be reconciled against them. - await this.ensureStructuredAgentSessionHost() + // A refused host is no host: startup goes on, and only structured requests are refused. + await ensureStructuredAgentSessionHostUnlessRefused(() => + this.ensureStructuredAgentSessionHost() + ) await this.refreshMobileSessionPtyRecords() await getStructuredAgentSessionHost()?.reconcileRestartLeases() } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 107cbd63407..adcd47baad3 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -166,6 +166,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. onSessionStatusChanged: (summary, options) => { + this.onStructuredSessionStatusForMail(summary) void maybeAutoRenameWorkspaceOnFirstStructuredTurn( summary, options, diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 7280f10a91f..404e09bf085 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -107,17 +107,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | null protected readonly prepareCodexStructuredLaunchFn: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) + | ((input: { launchEnv: NodeJS.ProcessEnv }) => string | null | Promise) | null protected readonly resolveCodexStructuredLaunchHomeFn: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) + | ((input: { launchEnv: NodeJS.ProcessEnv }) => string | null | Promise) | null protected readonly agentSessionClaimSigner: AgentSessionClaimSigner diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 281185e51dc..095ee58a37f 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -64,7 +64,6 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt if (opts.agentPrompt && startupPlan.followupPrompt) { throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) } - await this.markWorkspaceTrustedForAgent(opts.agent, workspace.connectionId, workspace.path) return { command: startupPlan.launchCommand, env: startupPlan.env, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index eeac6dd9a78..0aeacdc0e99 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -243,8 +243,6 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) } - await this.markWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) - return { ...opts, command: startupPlan.launchCommand, diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f22a09bac5e..8d29a0d360b 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -86,6 +86,14 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu notify: false }) } + const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable + // No host means no one can say which chats exist; with none on disk, empty is the answer. + this.structuredAgentSessionInventoryUnverifiable = + !host && this.hasPersistedStructuredAgentSessionStore() + // This restore published quietly; subscribers still hold the frames that said "cannot tell". + if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { + this.notifyMobileSessionTabSnapshots() + } } async publishStructuredAgentSessionTab(input: { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index d724207f921..f033cb456eb 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -121,6 +121,9 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionTabRestorePromise: Promise | null = null + // Whether the last tab restore ran with chats on disk but no host to list them. + protected structuredAgentSessionInventoryUnverifiable = false + protected structuredAgentSessionStartupRestorePromise: Promise | null = null protected mobileSessionTabsChangeSequence = 0 diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index bb03b17eca3..4d70572eb2a 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -11,7 +11,7 @@ import { import { withTimeout } from './runtime-async-boundaries' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptSettled } from './terminal-wait-detection' import type { RuntimeTerminalWait, @@ -80,7 +80,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith const ready = agent === 'antigravity' ? isAntigravityReadyPromptSnapshot(snapshotText) - : isKnownReadyPromptPreview(snapshotText) + : isKnownReadyPromptSettled(snapshotText) if (!blockedReason && !ready) { return } diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 30093c36413..ef54b73252c 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -96,14 +96,12 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { args: AiVaultPrepareSessionResumeArgs ) => Promise prepareCodexStructuredLaunch?: (input: { - workspacePath: string launchEnv: NodeJS.ProcessEnv }) => string | null | Promise // Why a sibling of prepare: record-less catalog reads must resolve the // same launch home with none of launch prep's side effects (no sync, no // bridge, no cleared selection). resolveCodexStructuredLaunchHome?: (input: { - workspacePath: string launchEnv: NodeJS.ProcessEnv }) => string | null | Promise buildAgentHookPtyEnv?: () => Record diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 4989245fdf6..a7175383df0 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' +import { localOrchestrationCliCommand } from './orchestration/cli-command' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' @@ -221,6 +222,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), + getCliCommand: localOrchestrationCliCommand, host: createStructuredMailboxPointerHost() }) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index ac120a80406..c5cdf188cd4 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -1,6 +1,72 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({}))) +vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) + import { OrcaRuntimeService } from './orca-runtime' +beforeEach(() => { + applyAgentWorkspaceTrust.mockClear() +}) + +function createCodexIntentRuntime(settings: Record) { + const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. + { getSettings: () => ({ agentDefaultEnv: { codex: {} }, ...settings }) } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + Object.assign(runtime, { + resolveStructuredAgentSessionLocation: vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })), + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repos/workspace-1' } })) + }) + const createIntent = () => + runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'codex' + }) + return { prepareCodexStructuredLaunch, createIntent } +} + +describe('structured Codex folder trust', () => { + it('pre-trusts the chat folder before launch preparation, as a Codex terminal launch does', async () => { + const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({}) + + await createIntent() + + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/repos/workspace-1', { + env: expect.any(Object), + claudeAuth: null, + wslDistro: null, + connectionId: null + }) + expect(applyAgentWorkspaceTrust.mock.invocationCallOrder[0]).toBeLessThan( + prepareCodexStructuredLaunch.mock.invocationCallOrder[0] + ) + }) + + it('writes nothing with the setting off, and still prepares the launch', async () => { + const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({ + agentWorkspaceTrustEnabled: false + }) + + await createIntent() + + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + expect(prepareCodexStructuredLaunch).toHaveBeenCalledTimes(1) + }) +}) + describe('structured agent-session create intent', () => { it('pins the selected Codex launch home after normal launch preparation', async () => { const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') @@ -52,7 +118,6 @@ describe('structured agent-session create intent', () => { }) expect(prepareCodexStructuredLaunch).toHaveBeenCalledWith({ - workspacePath: '/repos/workspace-1', launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) }) expect(intent.accountHome).toEqual({ @@ -82,7 +147,6 @@ describe('structured agent-session create intent', () => { // no cleared account selection — the read-only sibling answers instead. expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() expect(resolveCodexStructuredLaunchHome).toHaveBeenCalledWith({ - workspacePath: '', launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) }) expect(accountHome).toEqual({ variable: 'CODEX_HOME', path: '/accounts/selected/home' }) @@ -138,6 +202,8 @@ describe('structured agent-session create intent', () => { }) expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() + // Claude was never pre-trusted for a structured chat. + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() expect(intent.accountHome).toEqual({ variable: 'CLAUDE_CONFIG_DIR', path: '/configured/claude-home' diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 5e196c32c71..59e13676e63 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -146,10 +146,8 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC opts: { agent: TuiAgent; prompt: string; title?: string } ): Promise { const worktree = await this.resolveWorktreeSelector(worktreeSelector) - // Why: the trust write lands in an agent's config on the machine that runs it, keyed by the - // workspace path. `getRepo(id)` is host-blind, so reading `connectionId` off it wrote a remote - // path into the *client's* config — the agent on the host never sees the trust (#11163). - // Same shape as the folder-create trust write fixed alongside this; the agent-launch half. + // Why: `getRepo(id)` is host-blind; the same repo id on two hosts must build the launch for the + // host that owns this worktree (#11163). const resolution = resolveWorktreeLaunchHost(this.store?.getRepos() ?? [], worktree) if (resolution.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') @@ -159,7 +157,6 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC throw new Error('Repository for the selected workspace is no longer available.') } const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) - await this.markWorkspaceTrustedForAgent(opts.agent, resolution.connectionId, worktree.path) return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, diff --git a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts index c25dc33df87..30d6c61ff79 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -413,12 +413,14 @@ vi.mock('../../ipc/worktree-logic', async (importOriginal) => { vi.mock('../../ipc/filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn(async (pathValue: string) => pathValue), invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock, + invalidateAuthorizedRootsCacheForRepo: invalidateAuthorizedRootsCacheMock, isENOENT: (error: unknown) => Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') })) vi.mock('../../ipc/registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock + invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock, + invalidateAuthorizedRootsCacheForRepo: invalidateAuthorizedRootsCacheMock })) // Why: the real check also matches relay-rebuilt errors, which carry only the ENOENT message. diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index 68479f85e4a..fc1e83abdfa 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -4,14 +4,17 @@ import { getDefaultWorkspaceSession, join, makePaneKey, - markCodexProjectTrustedMock, - markCursorWorkspaceTrustedMock, mkdtemp, setPlatform, setTerminalViewAttributes, tmpdir } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' +import { getTerminalViewerColors } from '../terminal-view-attribute-store' +import type { + TerminalViewAttributes, + TerminalViewRgb +} from '../../../shared/terminal-view-attributes' import type { AgentSessionExecutionClaim, AgentSessionSurfaceBinding @@ -33,6 +36,21 @@ import { store } from '../orca-runtime-test-fixtures.spec' +function viewAttributes( + foreground: TerminalViewRgb, + background: TerminalViewRgb +): TerminalViewAttributes { + return { + foreground, + background, + cursor: [0xff, 0xff, 0xff], + ansi: Array.from({ length: 256 }, (): TerminalViewRgb => [0, 0, 0]), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: false + } +} + describe('OrcaRuntimeService', () => { it('preserves SSH dispatch authority commitment across transient relay loss', async () => { const targetId = 'ssh-1' @@ -171,15 +189,7 @@ describe('OrcaRuntimeService', () => { }) it('passes cached view colors to background agent spawns for source-owned startup replies', async () => { - setTerminalViewAttributes({ - foreground: [0xff, 0xff, 0xff], - background: [0x28, 0x2c, 0x34], - cursor: [0xff, 0xff, 0xff], - ansi: Array.from({ length: 256 }, () => [0, 0, 0] as [number, number, number]), - colorSchemeMode: 'dark', - cursorStyle: 'block', - cursorBlink: false - }) + setTerminalViewAttributes(viewAttributes([0xff, 0xff, 0xff], [0x28, 0x2c, 0x34])) const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -201,6 +211,53 @@ describe('OrcaRuntimeService', () => { ) }) + it("keeps a desktop host's own colours when a paired client creates a terminal", async () => { + setTerminalViewAttributes(viewAttributes([0xff, 0xff, 0xff], [0x28, 0x2c, 0x34])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-colors' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + terminalColorQueryReplies: { foreground: '#2e3434', background: '#ffffff' } + }) + + const hostColors = { foreground: '#ffffff', background: '#282c34' } + expect(getTerminalViewerColors()).toEqual(hostColors) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ terminalColorQueryReplies: hostColors }) + ) + }) + + it("takes the creating client's colours on a host with no window of its own", async () => { + // A client that predates terminal.setViewerColors reports its theme only here. + const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless-remote-colors' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + terminalColorQueryReplies: { foreground: '#2e3434', background: '#ffffff' } + }) + + expect(getTerminalViewerColors()).toEqual({ foreground: '#2e3434', background: '#ffffff' }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + terminalColorQueryReplies: { foreground: '#2e3434', background: '#ffffff' } + }) + ) + }) + it('does not register or publish a PTY incarnation that exited before spawn resolved', async () => { const runtime = new OrcaRuntimeService(store) const tabId = '11111111-1111-4111-8111-111111111111' @@ -374,7 +431,7 @@ describe('OrcaRuntimeService', () => { }) const spawnCall = spawn.mock.calls[0]?.[0] as - | { command?: string; env?: Record } + | { command?: string; launchAgent?: string; env?: Record } | undefined expect(spawnCall?.command).toBe("codex '--dangerously-bypass-approvals-and-sandbox'") expect(spawnCall?.env).toMatchObject({ @@ -382,10 +439,8 @@ describe('OrcaRuntimeService', () => { ORCA_WORKTREE_ID: TEST_WORKTREE_ID }) expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(UUID_RE) - expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) - expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan( - spawn.mock.invocationCallOrder[0]! - ) + // The spawn builder pre-trusts the workspace for the declared launch agent. + expect(spawnCall?.launchAgent).toBe('codex') }) // Why: `cursor` on PATH is the Cursor desktop launcher; only `cursor-agent` is @@ -421,7 +476,6 @@ describe('OrcaRuntimeService', () => { expect(spawnCall?.command).toBe("cursor-agent '--force'") expect(spawnCall?.launchAgent).toBe('cursor') expect(spawnCall?.env).toMatchObject({ CURSOR_PROFILE: 'captured' }) - expect(markCursorWorkspaceTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) }) it('resolves a startupAgent to the CLI binary on Windows, where `cursor` is the IDE', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index bf3e98020e5..47d7236dd9d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -7,7 +7,6 @@ import { homedir, ipcMain, join, - markCodexProjectTrustedMock, mkdtemp, randomUUID, registerSshGitProvider, @@ -242,10 +241,6 @@ describe('OrcaRuntimeService', () => { } }) ) - expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) - expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan( - webContents.send.mock.invocationCallOrder[0]! - ) }) it('injects runtime hook receiver env into terminal sessions', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts index d341496786e..1658c8690c7 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts @@ -122,7 +122,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('pre-marks remote Codex workspaces trusted before pasting startup drafts', async () => { + it('launches a remote Codex startup draft with its declared agent on the SSH host', async () => { detectRemoteAgentsMock.mockResolvedValue(['codex']) muxRequestMock.mockResolvedValue({ resolvedPath: '/home/dev' }) const created = { @@ -216,27 +216,16 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).not.toHaveBeenCalled() - expect(muxRequestMock).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/dev/.codex') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('[projects."/remote/mobile-codex-draft"]') - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('trust_level = "trusted"') - ) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-codex-draft', command: "codex '--dangerously-bypass-approvals-and-sandbox'", connectionId: 'ssh-1', - worktreeId: result.worktree.id + worktreeId: result.worktree.id, + // The spawn builder pre-trusts the workspace on the SSH host for this agent. + launchAgent: 'codex' }) ) - expect(fsProvider.writeFile.mock.invocationCallOrder[0]).toBeLessThan( - spawn.mock.invocationCallOrder[0]! - ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) } finally { unregisterSshFilesystemProvider('ssh-1') @@ -244,7 +233,7 @@ describe('OrcaRuntimeService', () => { } }) - it('pre-marks remote Codex workspaces trusted before explicit startup commands', async () => { + it('launches a remote explicit Codex startup command with its declared agent on the SSH host', async () => { muxRequestMock.mockResolvedValue({ resolvedPath: '/home/dev' }) const created = { path: '/remote/mobile-codex-command', @@ -334,26 +323,16 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).not.toHaveBeenCalled() - expect(muxRequestMock).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('[projects."/remote/mobile-codex-command"]') - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('trust_level = "trusted"') - ) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-codex-command', command: 'codex', connectionId: 'ssh-1', - worktreeId: result.worktree.id + worktreeId: result.worktree.id, + // The spawn builder pre-trusts the workspace on the SSH host for this agent. + launchAgent: 'codex' }) ) - expect(fsProvider.writeFile.mock.invocationCallOrder[0]).toBeLessThan( - spawn.mock.invocationCallOrder[0]! - ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) } finally { unregisterSshFilesystemProvider('ssh-1') diff --git a/src/main/runtime/orchestration/canonical-orca-session-id.ts b/src/main/runtime/orchestration/canonical-orca-session-id.ts index 7889e257f48..4aee8cbea15 100644 --- a/src/main/runtime/orchestration/canonical-orca-session-id.ts +++ b/src/main/runtime/orchestration/canonical-orca-session-id.ts @@ -1,7 +1,38 @@ -import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' +import { + clearedInto, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' -/** The Orca session id orchestration addresses a session by; every session-to-party step calls this. */ -export function canonicalOrcaSessionId(orcaSessionId: OrcaSessionId): OrcaSessionId { - // Later lineage canonicalization (a `/clear`ed session to its lineage root) plugs in here. - return orcaSessionId +/** + * The Orca session id orchestration addresses a session by: the first session of its `/clear` + * lineage, so a cleared chat keeps the address, Runs and mail it had. Every session-to-party step + * calls this. Without a record store there is no lineage to read, and the id stands for itself. + */ +export function canonicalOrcaSessionId( + orcaSessionId: OrcaSessionId, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): OrcaSessionId { + if (!store) { + return orcaSessionId + } + const clearedFrom = new Map() + for (const record of store.listRecords()) { + const next = clearedInto(record) + if (next) { + clearedFrom.set(next, record.sessionId) + } + } + // A clear chain is acyclic by construction; the visited set only bounds a corrupt store. + let root: string = orcaSessionId + const earlier = new Set([root]) + let prior = clearedFrom.get(root) + while (prior && !earlier.has(prior)) { + earlier.add(prior) + root = prior + prior = clearedFrom.get(root) + } + // Record ids are minted as Orca session ids; one that is not cannot name the conversation. + return isOrcaSessionId(root) ? root : orcaSessionId } diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index 809be9a4b88..af38b95a350 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -1,9 +1,20 @@ import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { isWslUncPath } from '../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { getAppEnvironment, hasAppEnvironment } from '../../../shared/app-environment' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' +/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */ +export function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined { + return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined +} + +/** What a local, non-WSL terminal is told to run; a structured session is always one. */ +export function localOrchestrationCliCommand(): OrchestrationCliCommand { + return runtimeOrchestrationCliCommand() ?? 'orca' +} + export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null isWsl: boolean | null | undefined diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index 67be16e6cf2..b51e5b4a8aa 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -9,7 +9,7 @@ import { recordedCreatorIdentity, type DispatchCreator } from '../dispatch-depth import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' -import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' export function createDispatchContext( this: OrchestrationDb, @@ -66,7 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, - assigneeOrcaSessionId: structuredWorkerOrcaSessionIdForIncarnation(processIncarnation), + assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId(processIncarnation), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index bc7b2c0cc0c..733833c9aca 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' -import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' export function prepareStartingWorkerAuthority( this: OrchestrationDb, @@ -64,7 +64,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, - structuredWorkerOrcaSessionIdForIncarnation(params.processIncarnation), + dispatchAssigneeOrcaSessionId(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 490cfbb1138..3d45c9a8630 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -1,6 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' -import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' /** * A start that dies before `prepareStartingWorkerAuthority` never filled the Dispatch context in, @@ -29,7 +29,7 @@ export function recordFailedStartDispatchIdentity( .run( resource.terminal_handle, resource.pane_key, - structuredWorkerOrcaSessionIdForIncarnation(resource.process_incarnation), + dispatchAssigneeOrcaSessionId(resource.process_incarnation), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts new file mode 100644 index 00000000000..52c75ece732 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts @@ -0,0 +1,11 @@ +import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../structured-worker-identity' +import { canonicalOrcaSessionId } from './canonical-orca-session-id' + +/** The Orca session id a Dispatch row stores for the structured worker a process incarnation names. */ +export function dispatchAssigneeOrcaSessionId( + processIncarnation: string | null | undefined +): OrcaSessionId | null { + const orcaSessionId = structuredWorkerOrcaSessionIdForIncarnation(processIncarnation) + return orcaSessionId === null ? null : canonicalOrcaSessionId(orcaSessionId) +} diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 52103cd5322..9b40bc08b0d 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -4,6 +4,11 @@ import { OrchestrationStructuredMailboxPointerDelivery, type StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' +import type { StructuredPointerOperationRow } from './db/messages/structured-pointer-operation-store' +import { + structuredPointerBatchFingerprint, + type StructuredPointerSubmission +} from './structured-pointer-operation-id' import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' @@ -84,13 +89,15 @@ function harness(options: { const mailbox = options.mailbox ?? 'dispatch:d1' const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId let journal = options.journal + // The session's recorded sends, as its journal reports them. + let submissions: StructuredPointerSubmission[] = [] const markAsDelivered = vi.fn() const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => ({ kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) })) const sendMock = vi.mocked(send) - const stored = new Map() + const stored = new Map() const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), hasOutstandingMailboxDelivery: (handle: string) => @@ -99,7 +106,7 @@ function harness(options: { getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], markAsDelivered, getStructuredPointerOperation: (key: string) => stored.get(key), - putStructuredPointerOperation: (row: { mailbox_handle: string }) => + putStructuredPointerOperation: (row: StructuredPointerOperationRow) => stored.set(row.mailbox_handle, row), deleteStructuredPointerOperation: (key: string) => stored.delete(key) } @@ -108,8 +115,10 @@ function harness(options: { getMessageWaiters: () => undefined, resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, + getCliCommand: () => 'orca-dev', host: { - readGateFacts: async () => (journal === null ? null : structuredSessionGateFacts(journal)), + readGateFacts: async () => + journal === null ? null : { ...structuredSessionGateFacts(journal), submissions }, currentFence: () => 4, send } @@ -121,6 +130,9 @@ function harness(options: { stored, setJournal: (next: AgentJournalRenderItem[] | null) => { journal = next + }, + setSubmissions: (next: StructuredPointerSubmission[]) => { + submissions = next } } } @@ -261,9 +273,10 @@ describe('structured mailbox pointer delivery', () => { expect(send).not.toHaveBeenCalled() }) - it('retries a rejected nudge on the next journal edge', async () => { + it('retries a rejected nudge on the next journal edge, under the same id', async () => { // A rejection consumes no mail and nothing else redrives this mailbox, so leaving it unparked - // stranded the worker until unrelated mail happened to arrive. + // stranded the worker until unrelated mail happened to arrive. The retry keeps the id: the host + // replays a recorded refusal rather than starting the agent again. const { delivery, send, markAsDelivered } = harness({ journal: idleJournal(), dispatchState: 'rejected' @@ -276,9 +289,143 @@ describe('structured mailbox pointer delivery', () => { delivery.onJournalActivity('session-1') await flush() expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[1]![0].operationId).toBe(first) + }) + + it('points again under a new id once a later send ran', async () => { + const { delivery, send, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([ + { clientMessageId: first, dispatchState: 'unknown', submittedAt: Date.now() }, + { clientMessageId: 'user-turn', dispatchState: 'accepted', submittedAt: Date.now() + 1 } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(2) expect(send.mock.calls[1]![0].operationId).not.toBe(first) }) + it('points once more under a new id for a send an earlier process left in doubt', async () => { + const { delivery, send, stored, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + stored.set('dispatch:d1', { + mailbox_handle: 'dispatch:d1', + session_id: 'session-1', + operation_id: 'earlier-process-op', + batch_fingerprint: structuredPointerBatchFingerprint('session-1', ['m1']), + minted_at_ms: 0 + }) + setSubmissions([ + { clientMessageId: 'earlier-process-op', dispatchState: 'unknown', submittedAt: Date.now() } + ]) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + const reminted = send.mock.calls[0]![0].operationId + expect(reminted).not.toBe('earlier-process-op') + // Minted by this process, the new id replays from here on. + setSubmissions([ + { clientMessageId: 'earlier-process-op', dispatchState: 'unknown', submittedAt: Date.now() }, + { clientMessageId: reminted, dispatchState: 'unknown', submittedAt: Date.now() } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send.mock.calls[1]![0].operationId).toBe(reminted) + }) + + it('keeps replaying its own send across a clock step, and re-mints only for a rewind that ran a turn', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { delivery, send, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + // The wall clock steps back an hour after the lane started: its own row is still its own. + vi.setSystemTime(Date.now() - 60 * 60 * 1000) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([ + { clientMessageId: first, dispatchState: 'unknown', submittedAt: Date.now() } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send.mock.calls[1]![0].operationId).toBe(first) + // A rewind dropped that send from the journal, and the person's turn ran after it. + setSubmissions([ + { clientMessageId: 'user-turn', dispatchState: 'accepted', submittedAt: Date.now() + 1 } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send.mock.calls[2]![0].operationId).not.toBe(first) + } finally { + vi.useRealTimers() + } + }) + + it('does not read a turn from before a backward clock step as one that ran after its pointer', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { delivery, send, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + const personTurn = { + clientMessageId: 'user-turn', + dispatchState: 'accepted' as const, + submittedAt: Date.now() + } + setSubmissions([personTurn]) + vi.setSystemTime(Date.now() - 2 * 60 * 1000) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([ + personTurn, + { clientMessageId: first, dispatchState: 'unknown', submittedAt: Date.now() } + ]) + for (let edge = 0; edge < 3; edge++) { + delivery.onJournalActivity('session-1') + await flush() + } + expect(send.mock.calls.map(([input]) => input.operationId)).toEqual([ + first, + first, + first, + first + ]) + } finally { + vi.useRealTimers() + } + }) + + it('stamps a pointer whose echo arrived after the lane stopped waiting, sending nothing more', async () => { + const { delivery, send, markAsDelivered, stored, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([{ clientMessageId: first, dispatchState: 'pending', submittedAt: Date.now() }]) + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + setSubmissions([{ clientMessageId: first, dispatchState: 'accepted', submittedAt: Date.now() }]) + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + expect(stored.has('dispatch:d1')).toBe(false) + }) + it('reuses one operation id for the same batch and re-mints when it grows', async () => { const { delivery, send, stored } = harness({ journal: idleJournal(), @@ -328,8 +475,9 @@ describe('forgetting one settled worker', () => { ? { sessionId, dispatchId: mailboxHandle.slice('dispatch:'.length) } : null }, + getCliCommand: () => 'orca', host: { - readGateFacts: async () => structuredSessionGateFacts(journal), + readGateFacts: async () => ({ ...structuredSessionGateFacts(journal), submissions: [] }), currentFence: () => 4, send } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index d3afed4923f..2a5e75a4ef8 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -9,17 +9,21 @@ * * Coordinators are in scope here, unlike the PTY lane's reasoning: a PTY coordinator blocks in * `check --wait`, where a waiter preempts pointer delivery, but a structured coordinator is a chat - * session whose turn ends — so nothing else would ever wake it for its own `run:` mail. + * session whose turn ends — so nothing else would ever prompt it for its own `run:` mail. */ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' +import type { OrchestrationCliCommand } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' -import { resolveStructuredPointerOperation } from './structured-pointer-operation-id' +import { + resolveStructuredPointerOperation, + type StructuredPointerSubmission +} from './structured-pointer-operation-id' import { decideStructuredSessionPointerDelivery, retainReasonForDispatch, @@ -48,9 +52,14 @@ export type StructuredPointerSendOutcome = | { kind: 'sent'; state: StructuredDispatchState } | { kind: 'unattached' } +export type StructuredPointerGateFacts = StructuredSessionGateFacts & { + /** Every send the session recorded, oldest first: what the lane's own sends settled as. */ + submissions: readonly StructuredPointerSubmission[] +} + export type StructuredMailboxPointerHost = { /** The idle gate, read off the session's full reduced timeline; `null` when it cannot be read. */ - readGateFacts: (sessionId: string) => Promise + readGateFacts: (sessionId: string) => Promise send: (input: { sessionId: string dispatchId: string | null @@ -73,6 +82,8 @@ type StructuredPointerDeliveryDependencies StructuredPointerTarget | null + /** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */ + getCliCommand: () => OrchestrationCliCommand host: StructuredMailboxPointerHost onRetain?: (input: { mailboxHandle: string @@ -95,6 +106,9 @@ export class OrchestrationStructuredMailboxPointerDelivery< * edge until the next explicit check. */ private readonly parkedUntilJournalEdge = new Map() + /** The operation id this lane last sent per mailbox: a row holding any other id outlived the + * process that minted it. A fact, not a clock reading, so no clock step can fake it. */ + private readonly sentOperationIds = new Map() constructor(private readonly deps: StructuredPointerDeliveryDependencies) {} @@ -176,6 +190,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } } + // A session whose agent is not running needs nothing first: an accepted send starts it. private async attempt( db: OrchestrationDb, mailboxHandle: string, @@ -198,7 +213,12 @@ export class OrchestrationStructuredMailboxPointerDelivery< const body: AgentJournalMessageItem = { kind: 'message', role: 'user', - blocks: [{ type: 'text', text: formatMessagePointer(unread.length, mailboxHandle).trim() }] + blocks: [ + { + type: 'text', + text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim() + } + ] } const staged = unread.map((message) => message.id) const operation = resolveStructuredPointerOperation({ @@ -206,8 +226,22 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle, sessionId, body, - messageIds: staged + messageIds: staged, + submissions: session?.submissions ?? [], + sentByThisProcess: this.sentOperationIds.get(mailboxHandle) }) + if (operation.kind === 'stamp') { + // A send this lane gave up waiting on ran after all. + db.markAsDelivered(staged) + db.deleteStructuredPointerOperation(mailboxHandle) + this.sentOperationIds.delete(mailboxHandle) + return + } + if (operation.kind === 'park') { + this.retain(mailboxHandle, sessionId, 'turn-unsettled', reservedTypes) + return + } + this.sentOperationIds.set(mailboxHandle, operation.operationId) const outcome = await this.deps.host.send({ sessionId, dispatchId: target.dispatchId, @@ -221,21 +255,15 @@ export class OrchestrationStructuredMailboxPointerDelivery< return } if (!structuredDispatchDelivered(outcome.state)) { - if (outcome.state === 'rejected') { - db.deleteStructuredPointerOperation(mailboxHandle) - } - this.retain( - mailboxHandle, - sessionId, - retainReasonForDispatch(outcome.state as Exclude), - reservedTypes - ) + // The row stays: resending under its id replays this verdict and starts nothing. + this.retain(mailboxHandle, sessionId, retainReasonForDispatch(outcome.state), reservedTypes) return } db.markAsDelivered(staged) // The nudge landed as its own turn, so the next settle edge is the natural retry point for // anything that arrives while it runs. db.deleteStructuredPointerOperation(mailboxHandle) + this.sentOperationIds.delete(mailboxHandle) } /** diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 1588e110a1d..fee8909a445 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -45,10 +45,13 @@ describe('structured mailbox pointer host', () => { // then delivered mid-turn, which Codex coalesces into the running turn and Claude folds into // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] - hostRef.current = { journalSnapshot: () => ({ items }) } + const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }] + hostRef.current = { journalSnapshot: () => ({ items, submissions }) } + // The recorded sends ride along: the lane reads what its own operation id settled as. expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ turnRunning: true, - awaitingHuman: false + awaitingHuman: false, + submissions }) }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index d53dc04600e..ecfdff0dff5 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -7,9 +7,14 @@ */ import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' +import { agentSessionSendSubmission } from '../../../shared/agent-session-wire' import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' +import type { + StructuredMailboxPointerHost, + StructuredPointerGateFacts +} from './structured-mailbox-pointer-delivery' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import { structuredSessionGateFacts, type StructuredSessionGateFacts @@ -42,13 +47,26 @@ export function structuredSessionPointerCallerKey(sessionId: string): string { export async function readStructuredSessionGateFacts( sessionId: string ): Promise { + const snapshot = await readSessionJournal(sessionId) + return snapshot ? structuredSessionGateFacts(snapshot.items) : null +} + +/** The pointer lane's gate: the shared idle facts, plus what each recorded send settled as. */ +async function readPointerGateFacts(sessionId: string): Promise { + const snapshot = await readSessionJournal(sessionId) + return snapshot + ? { ...structuredSessionGateFacts(snapshot.items), submissions: snapshot.submissions } + : null +} + +async function readSessionJournal(sessionId: string): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { // Opens a conversation the idle sweep closed; that starts no agent. - return structuredSessionGateFacts((await host.journalSnapshot(sessionId)).items) + return await host.journalSnapshot(sessionId) } catch (error) { // Not attached is a retain reason, not a failure; anything else is still unreadable. if ((error as Error)?.message !== AGENT_SESSION_NOT_ATTACHED.code) { @@ -61,7 +79,7 @@ export async function readStructuredSessionGateFacts( export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHost { return { readGateFacts(sessionId) { - return readStructuredSessionGateFacts(sessionId) + return readPointerGateFacts(sessionId) }, currentFence(sessionId) { @@ -98,17 +116,20 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo } // `pending` is not yet an acknowledgement; only `accepted` may consume mail. Accepted is not // delivered, so wait out a start; a wait that runs out parks for the next journal edge. + const answered = agentSessionSendSubmission(result.value) const submission = - result.value.submission.dispatchState === 'pending' - ? (( - await host - .waitForSendSettlement(input.sessionId, result.value.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS - }) - .catch(() => undefined) - )?.value.submission ?? result.value.submission) - : result.value.submission - const state = submission.dispatchState + answered?.dispatchState === 'pending' + ? (agentSessionSendSubmission( + ( + await host + .waitForSendSettlement(input.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value + ) ?? answered) + : answered + const state = submission?.dispatchState return { kind: 'sent', state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown' diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts index 35e75c865bc..fe891ef48ba 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from '../../../shared/agent-session-host-authority' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS +} from '../../../shared/agent-session-host-authority' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { + decideStructuredPointerAttempt, mintAgentSessionOperationId, - resolveStructuredPointerOperation + resolveStructuredPointerOperation, + type StructuredPointerSubmission } from './structured-pointer-operation-id' const OPERATION_ID_PATTERN = /^\d{13}-[0-9a-f]{32}$/ @@ -12,6 +17,27 @@ function body(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +/** The id this batch resolves to, as this process sends it; unrecorded unless `submissions` say. */ +function resolveId( + args: Omit< + Parameters[0], + 'submissions' | 'sentByThisProcess' + > & { submissions?: StructuredPointerSubmission[] } +): { + operationId: string + payloadFingerprint: string +} { + const resolved = resolveStructuredPointerOperation({ + ...args, + submissions: args.submissions ?? [], + sentByThisProcess: args.db.getStructuredPointerOperation(args.mailboxHandle)?.operation_id + }) + if (resolved.kind !== 'send') { + throw new Error(`expected a send, got ${resolved.kind}`) + } + return resolved +} + function fakeDb() { const rows = new Map() return { @@ -30,7 +56,7 @@ describe('structured pointer operation id', () => { it('reuses one id for the same batch', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -38,7 +64,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const second = resolveStructuredPointerOperation({ + const second = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -52,7 +78,7 @@ describe('structured pointer operation id', () => { it('re-mints when the batch grows', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -60,7 +86,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const grown = resolveStructuredPointerOperation({ + const grown = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -72,8 +98,9 @@ describe('structured pointer operation id', () => { }) it('never re-mints an ambiguous batch after the host replay window expires', () => { + // The host recorded the send `unknown`: whether the nudge landed is still open, however old. const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -81,12 +108,15 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const aged = resolveStructuredPointerOperation({ + const aged = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', body: body('2 messages'), messageIds: ['m1', 'm2'], + submissions: [ + { clientMessageId: first.operationId, dispatchState: 'unknown', submittedAt: 1_000 } + ], now: 1_000 + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 1 }) expect(aged.operationId).toBe(first.operationId) @@ -98,7 +128,7 @@ describe('structured pointer operation id', () => { // its ledger answer — `accepted`, with no turn sent — and the lane then marks the NEW mail // delivered. The worker is never told, and the mail is gone. const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -106,7 +136,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const different = resolveStructuredPointerOperation({ + const different = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -120,7 +150,7 @@ describe('structured pointer operation id', () => { it('re-mints when a retained batch is reordered or partly consumed', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -128,7 +158,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const shifted = resolveStructuredPointerOperation({ + const shifted = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -141,7 +171,7 @@ describe('structured pointer operation id', () => { it('re-mints when the mailbox moves to a different session', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -149,7 +179,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const moved = resolveStructuredPointerOperation({ + const moved = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's2', @@ -160,3 +190,99 @@ describe('structured pointer operation id', () => { expect(moved.operationId).not.toBe(first.operationId) }) }) + +describe('what a pointer attempt does with its operation row', () => { + const row = { + mailbox_handle: 'run:r1', + session_id: 's1', + operation_id: 'op1', + batch_fingerprint: 'batch-1', + minted_at_ms: 2_000 + } + + function decide( + submissions: StructuredPointerSubmission[], + overrides: { + batchFingerprint?: string + sessionId?: string + mintedByThisProcess?: boolean + now?: number + } = {} + ) { + return decideStructuredPointerAttempt({ + row, + sessionId: overrides.sessionId ?? 's1', + batchFingerprint: overrides.batchFingerprint ?? 'batch-1', + submissions, + mintedByThisProcess: overrides.mintedByThisProcess ?? true, + now: overrides.now ?? 3_000 + }) + } + + const sent = (dispatchState: StructuredPointerSubmission['dispatchState']) => ({ + clientMessageId: 'op1', + dispatchState, + submittedAt: 2_000 + }) + const userTurn = (dispatchState: StructuredPointerSubmission['dispatchState'], at = 2_500) => ({ + clientMessageId: 'user-1', + dispatchState, + submittedAt: at + }) + + it('mints for a new batch, a new session, or no row at all', () => { + expect(decide([], { batchFingerprint: 'batch-2' })).toBe('mint') + expect(decide([], { sessionId: 's2' })).toBe('mint') + expect( + decideStructuredPointerAttempt({ + row: undefined, + sessionId: 's1', + batchFingerprint: 'batch-1', + submissions: [], + mintedByThisProcess: false, + now: 0 + }) + ).toBe('mint') + }) + + it('sends under the same id when the host never recorded it', () => { + expect(decide([])).toBe('reuse') + // A turn that ran before the row was minted is no news. + expect(decide([userTurn('accepted', 1_500)])).toBe('reuse') + }) + + it('stamps a send that ran, and parks one still in flight', () => { + expect(decide([sent('accepted')])).toBe('stamp') + expect(decide([sent('pending')])).toBe('park') + }) + + it.each([ + ['in doubt', sent('unknown')], + ['refused', sent('rejected')] + ])('replays a send that was %s instead of starting the agent again', (_label, failed) => { + expect(decide([failed])).toBe('reuse') + // A later send that has not run yet is no evidence the agent works again. + expect(decide([failed, userTurn('pending')])).toBe('reuse') + // Age never re-mints a recorded send. + expect(decide([failed], { now: 2_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 1 })).toBe( + 'reuse' + ) + }) + + it('mints once the agent ran a turn after the row was minted, recorded send or not', () => { + expect(decide([sent('unknown'), userTurn('accepted')])).toBe('mint') + expect(decide([sent('rejected'), userTurn('accepted')])).toBe('mint') + // A rewind dropped the row's own send from the journal. + expect(decide([userTurn('accepted')])).toBe('mint') + }) + + it('mints a row an earlier process left behind, recorded send or not', () => { + expect(decide([sent('unknown')], { mintedByThisProcess: false })).toBe('mint') + expect(decide([], { mintedByThisProcess: false })).toBe('mint') + }) + + it('mints an unrecorded send once the host would refuse it as too old to admit', () => { + expect(decide([], { now: 2_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS })).toBe('reuse') + expect(decide([], { now: 2_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 1 })).toBe('mint') + }) +}) diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.ts index 0f258ba325f..66710b3b4f4 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.ts @@ -4,8 +4,11 @@ * Orchestration's own `msg_` ids do not match the host's `^\d{13}-[0-9a-f]{32}$` shape and are * refused before the first send, so the id is minted here instead. It is durable and reused across * retries, because the id IS the send's idempotency key: a fresh id for the same nudge would land - * as a second turn. It is re-minted only when the send is genuinely a different call — a different - * batch of mail, or a different session. Age cannot resolve delivery ambiguity. + * as a second turn, and the host replays a recorded id's verdict without reaching the provider, so + * a retry after a failed send starts nothing. It is re-minted only when the send is genuinely a + * different call: a different batch of mail or session, or one the journal shows is owed again + * (see `decideStructuredPointerAttempt`). Age never re-mints a send the host recorded: its verdict + * is the only evidence of whether the nudge landed. * * Reuse is keyed on the MESSAGE IDS in the batch, never on the pointer body: the body names only * how many messages are waiting, so two unrelated same-size batches share a fingerprint. Reusing a @@ -14,9 +17,67 @@ */ import { createHash, randomBytes } from 'node:crypto' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { OrchestrationDb } from './db' +import type { StructuredPointerOperationRow } from './db/messages/structured-pointer-operation-store' + +/** What the pointer lane reads off a session's journal for its own sends. */ +export type StructuredPointerSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'submittedAt' +> + +/** + * What to do with a mailbox's pointer, given its operation row and the session's recorded sends. + * + * - `stamp`: the row's send ran; the batch is pointed. + * - `park`: the row's send is still in flight; its settlement is the next edge. + * - `mint`: a new send. The batch or session changed; the agent ran a turn after the row was + * minted; an earlier process minted it, so its attempt died with that process; or the host never + * recorded it and would now refuse it as too old to admit. + * - `reuse`: resend under the row's id. Unrecorded, it is a first delivery; recorded as failed, the + * host replays that verdict and starts nothing, so a provider that dies on every turn is not + * restarted by every status edge, and a user's Stop stays stopped. + */ +export type StructuredPointerAttempt = 'mint' | 'reuse' | 'stamp' | 'park' + +export function decideStructuredPointerAttempt(input: { + row: StructuredPointerOperationRow | undefined + sessionId: string + batchFingerprint: string + /** The session's recorded sends; a rewind may have dropped the row's. */ + submissions: readonly StructuredPointerSubmission[] + /** Whether this process minted the row's id. */ + mintedByThisProcess: boolean + now: number +}): StructuredPointerAttempt { + const { row, submissions } = input + if ( + !row || + row.session_id !== input.sessionId || + row.batch_fingerprint !== input.batchFingerprint + ) { + return 'mint' + } + const sent = submissions.find((entry) => entry.clientMessageId === row.operation_id) + if (sent?.dispatchState === 'accepted') { + return 'stamp' + } + if (sent?.dispatchState === 'pending') { + return 'park' + } + const ranSince = submissions.some( + (entry) => entry.dispatchState === 'accepted' && entry.submittedAt > row.minted_at_ms + ) + const tooOldToAdmit = + !sent && input.now - row.minted_at_ms > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + return ranSince || !input.mintedByThisProcess || tooOldToAdmit ? 'mint' : 'reuse' +} export function mintAgentSessionOperationId(now: number): string { return `${String(now).padStart(13, '0')}-${randomBytes(16).toString('hex')}` @@ -43,6 +104,11 @@ export function structuredPointerPayloadFingerprint( }) } +export type StructuredPointerOperation = + | { kind: 'send'; operationId: string; payloadFingerprint: string } + | { kind: 'stamp' } + | { kind: 'park' } + export function resolveStructuredPointerOperation(args: { db: OrchestrationDb mailboxHandle: string @@ -50,18 +116,28 @@ export function resolveStructuredPointerOperation(args: { body: AgentJournalMessageItem /** The rows this nudge stands for; batch identity, not the body, decides reuse. */ messageIds: readonly string[] + submissions: readonly StructuredPointerSubmission[] + /** The operation id this process last sent for this mailbox, if any. */ + sentByThisProcess: string | undefined now?: number -}): { operationId: string; payloadFingerprint: string } { +}): StructuredPointerOperation { const now = args.now ?? Date.now() const payloadFingerprint = structuredPointerPayloadFingerprint(args.sessionId, args.body) const batchFingerprint = structuredPointerBatchFingerprint(args.sessionId, args.messageIds) const stored = args.db.getStructuredPointerOperation(args.mailboxHandle) - if ( - stored && - stored.session_id === args.sessionId && - stored.batch_fingerprint === batchFingerprint - ) { - return { operationId: stored.operation_id, payloadFingerprint } + const attempt = decideStructuredPointerAttempt({ + row: stored, + sessionId: args.sessionId, + batchFingerprint, + submissions: args.submissions, + mintedByThisProcess: stored?.operation_id === args.sentByThisProcess, + now + }) + if (attempt === 'stamp' || attempt === 'park') { + return { kind: attempt } + } + if (attempt === 'reuse' && stored) { + return { kind: 'send', operationId: stored.operation_id, payloadFingerprint } } const operationId = mintAgentSessionOperationId(now) args.db.putStructuredPointerOperation({ @@ -69,7 +145,11 @@ export function resolveStructuredPointerOperation(args: { session_id: args.sessionId, operation_id: operationId, batch_fingerprint: batchFingerprint, - minted_at_ms: now + // On the journal's clock too, so a backward clock step cannot date an earlier turn after it. + minted_at_ms: args.submissions.reduce( + (latest, entry) => Math.max(latest, entry.submittedAt), + now + ) }) - return { operationId, payloadFingerprint } + return { kind: 'send', operationId, payloadFingerprint } } diff --git a/src/main/runtime/orchestration/structured-session-lineage.ts b/src/main/runtime/orchestration/structured-session-lineage.ts new file mode 100644 index 00000000000..8cf6e94f12d --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-lineage.ts @@ -0,0 +1,44 @@ +/** + * A structured session's `/clear` lineage, read off the durable session records. `/clear` continues + * a chat in a new session; the committed clear on the old record names the session that replaced it. + * Derived from the records every time; nothing is rewritten at a clear. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' + +export type AgentSessionRecordReader = { + getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords: () => AgentSessionRecord[] + /** Absent on a store that predates tab visibility; every session then counts as open. */ + getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } +} + +/** Null until the agent-session host is installed; callers that must see records ensure it first. */ +export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +/** The session a committed `/clear` continued this one in, if any. */ +export function clearedInto(record: AgentSessionRecord): string | null { + const command = record.conversationCommand + return command?.command === 'clear' && command.phase === 'committed' + ? (command.replacementSessionId ?? null) + : null +} + +/** The session running the lineage now; null when the chain names a session with no record. */ +export function lineageLiveSession( + store: AgentSessionRecordReader, + sessionId: string +): AgentSessionRecord | null { + let live = store.getRecord(sessionId) + const later = new Set([sessionId]) + let next = live ? clearedInto(live) : null + while (live && next && !later.has(next)) { + later.add(next) + live = store.getRecord(next) + next = live ? clearedInto(live) : null + } + return live +} diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts new file mode 100644 index 00000000000..19a2a56a0ee --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -0,0 +1,93 @@ +/** + * A structured agent session as a mail address: `session:`, the Orca-minted id every agent is + * told is its public address. Recipient routing and pointer delivery both read these rules off the + * durable session record, so the two can never disagree about which sessions mail can reach. + * + * The address names a conversation, not one session of it: any session of a `/clear` lineage names + * the lineage root's address (`canonicalOrcaSessionId`), and mail reaches the lineage's live session. + * + * A released lease does not end a session: the host stops an idle chat's agent, and the send that + * points its mail starts it again. For mail, a conversation has ended only when its chat was closed. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { OrchestrationDb } from './db' +import { OrchestrationError } from './orchestration-error' +import { resolveOrcaSessionParty, type OrchestrationSessionParty } from './orchestration-party' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +export type OrcaAgentSessionLookup = + | { kind: 'found'; record: AgentSessionRecord } + /** The id is a provider's own session id, which rotates on `/clear`; this names the Orca id. */ + | { kind: 'provider-id'; orcaSessionId: string } + | { kind: 'unknown' } + +export function lookupOrcaAgentSession( + store: AgentSessionRecordReader, + id: string +): OrcaAgentSessionLookup { + const record = store.getRecord(id) + if (record) { + return { kind: 'found', record } + } + const owner = store + .listRecords() + .find((candidate) => + candidate.providerHandleChain.some(({ handle }) => + handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id + ) + ) + return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' } +} + +export type StructuredSessionMailReach = + /** `session` is the conversation's live session, the one its mail reaches now. */ + | { kind: 'reachable'; session: AgentSessionRecord } + | { kind: 'other-host' } + | { kind: 'ended'; reason: 'closed' | 'worker-identity-lost' | 'continuation-missing' } + +/** Whether mail to `record`'s conversation can reach the session that runs it now. */ +export function structuredSessionMailReach( + store: AgentSessionRecordReader, + record: AgentSessionRecord, + db: OrchestrationDb | null | undefined +): StructuredSessionMailReach { + const live = lineageLiveSession(store, record.sessionId) + if (!live) { + return { kind: 'ended', reason: 'continuation-missing' } + } + if (!structuredWorkerHostScope(live.location)) { + return { kind: 'other-host' } + } + if (isOrcaSessionId(live.sessionId) && !addressableSessionParty(live.sessionId, db)) { + // Why: it can no longer act (the caller resolver refuses it), so mail to it could never be read. + return { kind: 'ended', reason: 'worker-identity-lost' } + } + const visible = store.getVisibleSessionTabIndex?.() + if (visible?.present && !visible.sessionIds.includes(live.sessionId)) { + // Why: reviving a chat the user closed would run turns nobody can see; its mail waits instead. + return { kind: 'ended', reason: 'closed' } + } + return { kind: 'reachable', session: live } +} + +/** + * The party a session resolves to, or null for a worker whose identity this host lost: the party + * resolver refuses it in every role, so it can never read mail and none is owed to it. + */ +export function addressableSessionParty( + sessionId: OrcaSessionId, + db: OrchestrationDb | null | undefined +): OrchestrationSessionParty | null { + try { + return resolveOrcaSessionParty(sessionId, db) + } catch (error) { + if (error instanceof OrchestrationError && error.code === CODES.notLive) { + return null + } + throw error + } +} diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts new file mode 100644 index 00000000000..5ed9c1ce26f --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -0,0 +1,531 @@ +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 { + getAppEnvironment, + hasAppEnvironment, + setAppEnvironment, + type AppEnvironment +} from '../../../shared/app-environment' +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { OrcaRuntimeWithGetPtyRecordForPaneKey } = + await import('../orca-runtime-get-pty-record-for-pane-key') +const { OrchestrationDb } = await import('./db') +const { resolveOrcaSessionParty } = await import('./orchestration-party') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('../structured-worker-identity') + +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT) + +/** The real methods through the real prototype chain; a re-declared copy would pin nothing. */ +class MailTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey { + target(mailboxHandle: string): unknown { + return this.resolveStructuredMailboxTarget(mailboxHandle) + } +} + +type Store = { + records: Map + visible: { present: boolean; sessionIds: string[] } +} + +function chatRecord( + lease: Partial = {}, + extra: Partial = {} +): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: CHAT, runtimeKind: 'native', ...lease }) + ), + ...extra + } +} + +function installStore(record: AgentSessionRecord | null, visible = true): Store { + const store: Store = { + records: new Map(record ? [[record.sessionId, record]] : []), + visible: { present: true, sessionIds: visible && record ? [record.sessionId] : [] } + } + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => store.records.get(sessionId) ?? null, + listRecords: () => [...store.records.values()], + getVisibleSessionTabIndex: () => store.visible + } + } + } + return store +} + +let db: InstanceType + +function probe(extra: Record = {}): MailTargetProbe { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a prototype-only probe; every field the methods read is assigned below. + return Object.assign(Object.create(MailTargetProbe.prototype), { + _orchestrationDb: db, + ptysById: new Map(), + ...extra + }) as MailTargetProbe +} + +function chatCoordinatedRun(): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT + }).id +} + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + hostRef.current = null +}) + +afterEach(() => { + db.close() +}) + +describe('a Run whose coordinator is a chat (an Orca session id, no handle)', () => { + it('delivers its mailbox to that session', () => { + // The defect this pins: the resolver read only `coordinator_handle`, which a chat never has, so + // neither lane claimed the Run mailbox and a worker's result never reached the chat. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('still delivers once the host has evicted the chat, so the delivery can wake it', () => { + installStore(chatRecord({ claimStatus: 'released', ownerProcess: null })) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('does not deliver to a chat that was closed, cleared into no known session, or runs on another host', () => { + const runId = chatCoordinatedRun() + installStore(chatRecord(), false) + expect(probe().target(`run:${runId}`)).toBeNull() + + installStore( + chatRecord( + {}, + { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64', + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + } + ) + ) + expect(probe().target(`run:${runId}`)).toBeNull() + + const remote = chatRecord() + installStore({ ...remote, location: { ...remote.location, executionHostId: 'ssh:box' } }) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + it('does not deliver to an Orca session id written at an earlier generation of the Run', () => { + // An older binary's rebind or unbind bumps the generation and leaves the id behind. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + db.db + .prepare('UPDATE runs SET consumer_generation = consumer_generation + 1 WHERE id = ?') + .run(runId) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + it('ignores an Orca session id left beside a PTY handle; the handle owns the Run', () => { + installStore(chatRecord()) + const runId = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_c:11111111-1111-4111-8111-111111111111', + coordinatorOrcaSessionId: CHAT + }).id + expect(probe().target(`run:${runId}`)).toBeNull() + }) +}) + +describe('a session addressed directly', () => { + it('owns its `session:` mailbox', () => { + installStore(chatRecord()) + expect(probe().target(CHAT_ADDRESS)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('claims nothing for a malformed session address', () => { + installStore(chatRecord()) + expect(probe().target('session:term_abc')).toBeNull() + }) +}) + +describe('the idle edge of a structured session', () => { + it('re-derives and delivers the mailboxes the session owns, and nothing while it works', () => { + installStore(chatRecord()) + const runId = chatCoordinatedRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${runId}`, + subject: 'done', + runId, + type: 'status' + }) + const delivered: string[] = [] + const runtime = probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() + }) + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'working' }) + expect(delivered).toEqual([]) + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([`run:${runId}`]) + }) + + it('points direct mail at a session that coordinates nothing', () => { + installStore(chatRecord()) + db.insertMessage({ from: 'term_peer', to: CHAT_ADDRESS, subject: 'hi', type: 'status' }) + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([CHAT_ADDRESS]) + }) +}) + +describe('the idle edge after a restart, before any orchestration call', () => { + let userData: string + let previousEnvironment: AppEnvironment | null + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-idle-edge-db-')) + previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null + setAppEnvironment({ + getPath: () => userData, + getAppPath: () => userData, + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + }) + + afterEach(() => { + if (previousEnvironment) { + setAppEnvironment(previousEnvironment) + } + rmSync(userData, { recursive: true, force: true }) + }) + + /** A runtime whose database has not been opened in this process yet. */ + function restarted(delivered: string[]): MailTargetProbe { + return probe({ + _orchestrationDb: null, + ensureOrchestrationFederationRelay: vi.fn(), + scheduleRestoredMessageRepoints: vi.fn(), + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }) + } + + it('opens an existing orchestration database itself, so mail stored before the restart is redriven', () => { + // The strand this pins: the edge read the raw database field, null until the first + // orchestration RPC opened it, so a restarted chat's idle edges silently redrove nothing. + installStore(chatRecord()) + const stored = new OrchestrationDb(join(userData, 'orchestration.db')) + const runId = stored.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT + }).id + stored.close() + const delivered: string[] = [] + const runtime = restarted(delivered) + + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + + expect(delivered).toEqual([`run:${runId}`]) + runtime.getOrchestrationDb().close() + }) + + it('creates no database for a profile that never orchestrated, and says nothing', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const delivered: string[] = [] + + restarted(delivered).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + + expect(delivered).toEqual([]) + expect(existsSync(join(userData, 'orchestration.db'))).toBe(false) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('says so when the database cannot be opened, instead of skipping silently', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deliver = vi.fn() + probe({ + _orchestrationDb: null, + getExistingOrchestrationDb: () => { + throw new Error('userData unavailable') + }, + deliverPendingMessagesForHandle: deliver, + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(deliver).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[orchestration] skipped a structured session mail edge: no database', + { sessionId: CHAT, error: 'userData unavailable' } + ) + warn.mockRestore() + }) +}) + +describe('a coordinator chat continued by /clear', () => { + const MIDDLE = testOrcaSessionId('clear-fedcba9876543210fedcba9876543210fedcba98') + const SUCCESSOR = testOrcaSessionId('clear-0123456789abcdef0123456789abcdef01234567') + + function sessionRecord(sessionId: string, clearedInto?: string): AgentSessionRecord { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeKind: 'native', + ...(clearedInto ? { claimStatus: 'released' as const, ownerProcess: null } : {}) + }) + ) + return clearedInto + ? { + ...record, + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: clearedInto, + operationId: `op-${sessionId}`, + callerKey: 'caller', + phase: 'committed' + } + } + : record + } + + /** CHAT cleared into each of `chain` in turn; the last one is live and its tab is open. */ + function installLineage(...chain: string[]): void { + const lineage = [CHAT, ...chain] + const store = installStore(null) + lineage.forEach((sessionId, index) => + store.records.set(sessionId, sessionRecord(sessionId, lineage[index + 1])) + ) + store.visible.sessionIds.push(lineage.at(-1)!) + } + + function idleEdge(sessionId: string): string[] { + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + return delivered + } + + function runCreatedBy(sessionId: OrcaSessionId): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: resolveOrcaSessionParty(sessionId, db).orcaSessionId + }).id + } + + it('stores a Dispatch assignee by the lineage root of the session its incarnation names', () => { + installLineage(SUCCESSOR) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: chatCoordinatedRun(), spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SUCCESSOR), + processIncarnation: structuredWorkerProcessIncarnation(SUCCESSOR), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(db.getDispatchContextById(dispatch.id)?.assignee_orca_session_id).toBe(CHAT) + }) + + it("never unbinds the successor's own Run", () => { + // The strand this pins: the successor's own run-create, then its idle edge rebinding the + // predecessor's Run through an exclusive bind, which unbound the Run the successor created. + installLineage(SUCCESSOR) + chatCoordinatedRun() + const ownRun = runCreatedBy(SUCCESSOR) + const generation = db.getRunRaw(ownRun)!.consumer_generation + + expect(idleEdge(SUCCESSOR)).toContain(`run:${ownRun}`) + idleEdge(SUCCESSOR) + + const successor = resolveOrcaSessionParty(SUCCESSOR, db) + expect(db.getRunRaw(ownRun)).toMatchObject({ + coordinator_orca_session_id: successor.orcaSessionId, + consumer_generation: generation + }) + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(ownRun) + }) + + it('keeps a Run bound, unrewritten, across a chain of clears, and delivers it to the live end', () => { + installLineage(MIDDLE) + const runId = chatCoordinatedRun() + const generation = db.getRunRaw(runId)!.consumer_generation + idleEdge(MIDDLE) + installLineage(MIDDLE, SUCCESSOR) + expect(idleEdge(SUCCESSOR)).toContain(`run:${runId}`) + + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_orca_session_id: CHAT, + consumer_generation: generation + }) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(db.getCurrentRunForCoordinator(resolveOrcaSessionParty(member, db))?.id).toBe(runId) + } + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: SUCCESSOR, dispatchId: null }) + }) + + it('keeps the Run a middle session created bound after the next clear', () => { + // The chain strand: adopting each predecessor's Run in turn unbound all but the last adopted. + installLineage(MIDDLE) + runCreatedBy(CHAT) + const middleRun = runCreatedBy(MIDDLE) + const generation = db.getRunRaw(middleRun)!.consumer_generation + installLineage(MIDDLE, SUCCESSOR) + idleEdge(SUCCESSOR) + + const successor = resolveOrcaSessionParty(SUCCESSOR, db) + expect(db.getRunRaw(middleRun)).toMatchObject({ + coordinator_orca_session_id: successor.orcaSessionId, + consumer_generation: generation + }) + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(middleRun) + }) + + it('reaches the live session through any spelling of the conversation, and stores one', () => { + installLineage(MIDDLE, SUCCESSOR) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(resolveOrcaSessionParty(member, db)).toMatchObject({ + orcaSessionId: CHAT, + address: CHAT_ADDRESS + }) + expect(probe().target(`session:${member}`)).toEqual({ + sessionId: SUCCESSOR, + dispatchId: null + }) + } + const direct = db.insertMessage({ + from: 'term_peer', + to: CHAT_ADDRESS, + subject: 'hi', + type: 'status' + }) + expect(idleEdge(SUCCESSOR)).toEqual([CHAT_ADDRESS]) + expect(db.getMessageById(direct.id)).toMatchObject({ to_handle: CHAT_ADDRESS, read: 0 }) + }) +}) + +describe('a structured worker continued by /clear', () => { + const WORKER = testOrcaSessionId('9c2e4a61-3f7b-4d8e-b105-6a2d8e4f1c93') + const WORKER_SUCCESSOR = testOrcaSessionId('clear-a1b2c3d4e5f60718293a4b5c6d7e8f9012345678') + + afterEach(() => { + structuredWorkerIdentities.clear() + }) + + /** A worker minted for WORKER, whose conversation `/clear` continued in WORKER_SUCCESSOR. */ + function clearedWorker(): { handle: string; paneKey: string } { + const store = installStore(null) + const minted = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: WORKER, runtimeKind: 'native' }) + ) + store.records.set(WORKER, { + ...minted, + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: WORKER_SUCCESSOR, + operationId: 'op-worker', + callerKey: 'caller', + phase: 'committed' + } + }) + store.records.set( + WORKER_SUCCESSOR, + agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: WORKER_SUCCESSOR, runtimeKind: 'native' }) + ) + ) + const identity = structuredWorkerIdentities.register({ + handle: mintStructuredWorkerHandle(), + sessionId: WORKER, + agent: 'codex', + paneKey: mintStructuredWorkerPaneKey(WORKER), + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return { handle: identity.handle, paneKey: identity.paneKey } + } + + it("delivers mail at the worker's handle to the live successor, as a terminal keeps its handle", () => { + // The strand this pins: the handle resolved to the session minted for it, which `/clear` + // replaced, so the worker's own mail was pointed at a session that no longer runs its turns. + const { handle } = clearedWorker() + expect(probe().target(handle)).toEqual({ sessionId: WORKER_SUCCESSOR, dispatchId: null }) + }) + + it("delivers the worker's dispatch mailbox and a Run it coordinates to the live successor", () => { + const { handle, paneKey } = clearedWorker() + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: chatCoordinatedRun(), spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(probe().target(`dispatch:${dispatch.id}`)).toEqual({ + sessionId: WORKER_SUCCESSOR, + dispatchId: dispatch.id + }) + const workerRun = db.createRun({ + objective: 'o', + coordinatorHandle: handle, + coordinatorPaneKey: paneKey + }).id + expect(probe().target(`run:${workerRun}`)).toEqual({ + sessionId: WORKER_SUCCESSOR, + dispatchId: null + }) + }) +}) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts new file mode 100644 index 00000000000..e71c08c512e --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -0,0 +1,128 @@ +/** + * Where a mailbox owned by a structured session is delivered: a chat that coordinates a Run + * (`run:` with no coordinator handle), a session addressed directly at `session:`, and the + * live session behind a structured worker's handle. The session is resolved here, never a pane, and + * takes the pointer as a session turn. + */ + +import { + ORCA_SESSION_ADDRESS_PREFIX, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' +import type { OrchestrationDb } from './db' +import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' +import { + addressableSessionParty, + structuredSessionMailReach +} from './structured-session-mail-address' +import { + lineageLiveSession, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' +import type { RunRow } from './types' + +/** + * The session a Run's coordinator binding names when that binding has no handle. A structured + * worker coordinates by its own handle and resolves through it, so only a handle-less binding names + * a session here, and only by an Orca session id that still counts (see + * `currentRunCoordinatorOrcaSessionId`). + */ +export function handleLessCoordinatorSessionId( + run: Pick< + RunRow, + | 'coordinator_handle' + | 'coordinator_orca_session_id' + | 'coordinator_orca_session_id_generation' + | 'consumer_generation' + > +): OrcaSessionId | null { + if (run.coordinator_handle !== null) { + return null + } + return currentRunCoordinatorOrcaSessionId(run) +} + +/** + * The structured-lane target for `sessionId`'s conversation: its live session, whichever session of + * the lineage was named; null when mail cannot reach it here. + */ +export function structuredSessionMailTarget( + sessionId: string, + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): StructuredPointerTarget | null { + const record = store?.getRecord(sessionId) + const reach = store && record ? structuredSessionMailReach(store, record, db) : null + return reach?.kind === 'reachable' + ? { sessionId: reach.session.sessionId, dispatchId: null } + : null +} + +/** + * The session a structured worker's mail reaches: the one minted for it, or that session's live + * `/clear` successor, which carries on as the worker the way a terminal keeps its handle. + */ +export function structuredWorkerMailSessionId( + mintedSessionId: string, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): string | null { + const live = store ? lineageLiveSession(store, mintedSessionId) : null + return live && structuredWorkerHostScope(live.location) ? live.sessionId : null +} + +/** + * The target of a `session:` mailbox; `undefined` when the handle is not a session address at + * all, so other address forms keep their own resolution. + */ +export function structuredSessionAddressTarget( + mailboxHandle: string, + db: OrchestrationDb | null | undefined +): StructuredPointerTarget | null | undefined { + if (!mailboxHandle.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + return undefined + } + const sessionId = parseOrcaSessionAddress(mailboxHandle) + return sessionId ? structuredSessionMailTarget(sessionId, db) : null +} + +/** + * Every mailbox a session reads for itself: the Runs it coordinates and its own direct mail. + * Re-derived from the database on each idle edge rather than remembered, so mail that arrived + * while the session could not take it (mid-turn, closed) is found again. + */ +export function structuredSessionOwnedMailboxes(sessionId: string, db: OrchestrationDb): string[] { + const party = isOrcaSessionId(sessionId) ? addressableSessionParty(sessionId, db) : null + if (!party) { + return [] + } + const mailboxes = db.runsBoundToCoordinator(party).map((run) => `run:${run.id}`) + if (db.getUnreadDirectMessageTypes(party.address).length > 0) { + mailboxes.push(party.address) + } + return mailboxes +} + +/** The mailboxes a session's idle edge re-derives, opening an existing database if nothing has + * yet: after a restart this edge is what redrives mail stored before it. No database file means + * no mail, so `openDb` answers null and nothing is created. */ +export function structuredSessionIdleEdgeMailboxes( + sessionId: string, + openDb: () => OrchestrationDb | null +): string[] { + let db: OrchestrationDb | null + try { + db = openDb() + } catch (error) { + console.warn('[orchestration] skipped a structured session mail edge: no database', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + return db ? structuredSessionOwnedMailboxes(sessionId, db) : [] +} diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 040bdd43190..4e838e313f8 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -99,7 +99,7 @@ export function decideStructuredSessionPointerDelivery(input: { * adapters cannot tell them apart — so it must retain. Treating it as delivered * would drop mail whenever a child died mid-send. */ -export function structuredDispatchDelivered(state: StructuredDispatchState): boolean { +export function structuredDispatchDelivered(state: StructuredDispatchState): state is 'accepted' { return state === 'accepted' } diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index c553e8e39d1..43b7fe2be45 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -56,6 +56,8 @@ function installHost(options: { hostRef.current = { deps: { store: { + // No committed /clear: each session is its own lineage's root. + listRecords: () => [], getRecord: (sessionId: string) => ({ sessionId, diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.test.ts b/src/main/runtime/orchestration/structured-worker-journal-page.test.ts new file mode 100644 index 00000000000..d00d411a067 --- /dev/null +++ b/src/main/runtime/orchestration/structured-worker-journal-page.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalProducerLinkage +} from '../../../shared/agent-session-journal-types' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryRequest } from '../../../shared/agent-session-wire' +import { createTrackedJournalOpener } from '../../native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store' +import type { AgentSessionHistoryScope } from '../../native-chat/agent-session-wire/agent-session-history-page' +import { readStructuredAgentSessionHistoryResult } from '../../native-chat/agent-session-wire/structured-agent-session-history-result' +import { + readStructuredJournalPage, + STRUCTURED_JOURNAL_PAGE_LIMIT +} from './structured-worker-journal-page' + +const host = vi.hoisted(() => { + const state: { journal: AgentSessionJournal | null } = { journal: null } + return state +}) + +// The host's read, over a real journal: only the conversation lookup is stubbed. +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => ({ + history: async (request: AgentSessionHistoryRequest, scope?: AgentSessionHistoryScope) => + readStructuredAgentSessionHistoryResult({ + journal: host.journal!, + record: null, + request, + scope + }) + }) +})) + +const journals = createTrackedJournalOpener() +let root: string +let clock = 1_000 +let ordinal = 0 + +function said(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function append(text: string, linkage: AgentJournalProducerLinkage = {}): Promise { + ordinal += 1 + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'claude-1', + uuid: `uuid-${ordinal}` + } + await host.journal!.appendItem(identity, said(text), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + ...linkage + }) +} + +const subagent: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-worker-journal-page-')) + clock = 1_000 + ordinal = 0 + host.journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'claude-1', leafUuid: null } + }, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => 'epoch-1' + }) +}) + +afterEach(async () => { + await journals.closeAll() + host.journal = null + await rm(root, { recursive: true, force: true }) +}) + +describe("a worker's journal page", () => { + it("is the newest page of the worker's own rows, however many subagent rows are newer", async () => { + await append('worker-older') + await append('worker-newest') + for (let index = 0; index < STRUCTURED_JOURNAL_PAGE_LIMIT + 60; index += 1) { + await append(`child-${index}`, subagent) + } + + const page = await readStructuredJournalPage('session-1') + + expect(page?.items.map((item) => item.body)).toEqual([ + said('worker-older'), + said('worker-newest') + ]) + expect(page?.hasOlder).toBe(false) + }) + + it('says there is older history when the worker has more of its own rows than a page', async () => { + for (let index = 0; index < STRUCTURED_JOURNAL_PAGE_LIMIT + 5; index += 1) { + await append(`worker-${index}`) + await append(`child-${index}`, subagent) + } + + const page = await readStructuredJournalPage('session-1') + + expect(page?.items).toHaveLength(STRUCTURED_JOURNAL_PAGE_LIMIT) + expect(page?.items.every((item) => item.agentId === undefined)).toBe(true) + expect(page?.items.at(-1)?.body).toEqual(said(`worker-${STRUCTURED_JOURNAL_PAGE_LIMIT + 4}`)) + expect(page?.hasOlder).toBe(true) + }) +}) diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.ts b/src/main/runtime/orchestration/structured-worker-journal-page.ts index 8d260339fa6..eb62b92304a 100644 --- a/src/main/runtime/orchestration/structured-worker-journal-page.ts +++ b/src/main/runtime/orchestration/structured-worker-journal-page.ts @@ -4,6 +4,11 @@ * `worker-read`, the release archive and `terminal read` all want the same thing — the newest page * of a session's reduced timeline, and `null` rather than a throw when the session is not attached. * It lives here so none of them can drift onto a different page size or a different failure shape. + * + * Each reads the worker's own conversation: the newest page of its OWN rows, windowed before the + * limit so a subagent's burst cannot crowd them out. A subagent's rows are that subagent's, and a + * reader of the worker must never take them for the worker's words; the worker's spawn roster, + * which stays, is the one line that says a subagent ran. */ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' @@ -26,11 +31,10 @@ export async function readStructuredJournalPage( return null } try { - const result = await host.history({ - sessionId, - direction: 'tail', - limit: STRUCTURED_JOURNAL_PAGE_LIMIT - }) + const result = await host.history( + { sessionId, direction: 'tail', limit: STRUCTURED_JOURNAL_PAGE_LIMIT }, + 'own-agent' + ) return { items: result.page.items, hasOlder: result.page.hasOlder } } catch { return null diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index d0b4ce23127..802b724a2aa 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -299,48 +299,3 @@ describe('worker transcript wire bounds', () => { ) }) }) - -describe("worker transcript wire bounds — a subagent's line names its agent and nothing more", () => { - it('serves the producing agent id, bounded like the roster key, and drops provenance', () => { - const longId = `task-${'x'.repeat(2_000)}` - const result = boundWorkerTranscriptMessages([ - { - id: 'child-line', - role: 'assistant', - timestamp: null, - source: 'transcript', - blocks: [{ type: 'text', text: 'The PR is CLEAN.' }], - agentId: longId, - parentAgentId: 'task-parent', - providerParentRef: 'toolu_provider_call', - producerKind: 'agent', - attempt: 2 - }, - { - id: 'roster', - role: 'system', - timestamp: null, - source: 'transcript', - blocks: [ - { - type: 'subagent-group', - groupId: 'group-1', - agents: [{ id: longId, label: 'review the PR', state: 'working' }] - } - ] - } - ]) - const [child, roster] = result.messages - expect(child).not.toHaveProperty('providerParentRef') - expect(child).not.toHaveProperty('parentAgentId') - expect(child).not.toHaveProperty('producerKind') - expect(child).not.toHaveProperty('attempt') - // The line's agent id and the roster entry that names it bound to the same key, - // so the reader can still put a name to the line. - const rosterBlock = roster?.blocks[0] - expect(rosterBlock?.type).toBe('subagent-group') - const entryId = rosterBlock?.type === 'subagent-group' ? rosterBlock.agents[0]?.id : undefined - expect(child?.agentId).toBeDefined() - expect(child?.agentId).toBe(entryId) - }) -}) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index b62687bd813..684dfc771e2 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' -import { agentJournalItemSubagentId } from '../../../shared/agent-session-journal-producer' import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds' import { boundWorkerTranscriptActivityBlock } from './worker-transcript-activity-block-bounds' @@ -110,24 +109,11 @@ function boundMessage( markClipped(state, 'Some transcript blocks were omitted from oversized messages.') } // The journal position only orders a live list; a worker read is already in order. - // Of the producer linkage only the agent's id is served, so a reader can say whose - // line it is; the rest is provenance. - const { - journalPosition: _journalPosition, - agentId: _agentId, - parentAgentId: _parentAgentId, - providerParentRef: _providerParentRef, - producerKind: _producerKind, - attempt: _attempt, - ...served - } = message - const subagentId = agentJournalItemSubagentId(message) + const { journalPosition: _journalPosition, ...served } = message return { ...served, id: boundIdentifier(message.id, transcriptPath, state), ...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}), - // Same key as the roster entry that names it, so it is bounded the same way. - ...(subagentId === null ? {} : { agentId: boundEntryId(subagentId, state) }), blocks: blocks.map((block) => boundBlock(block, state)) } } diff --git a/src/main/runtime/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts index 5271963a598..f8f3fd12037 100644 --- a/src/main/runtime/remote-desktop-driver.test.ts +++ b/src/main/runtime/remote-desktop-driver.test.ts @@ -29,7 +29,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } }) vi.mock('../ipc/registered-worktree-roots-cache', () => ({ - invalidateAuthorizedRootsCache: vi.fn() + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() })) vi.mock('../git/repo', async (importOriginal) => { const actual = (await importOriginal()) as Record diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 624557c1f7f..fa9106dc287 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -20,6 +20,7 @@ import { agentSessionRefusalError, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -356,6 +357,34 @@ describe('thrown agent-session refusals', () => { }) }) + // The shape every client test of a thrown refusal feeds in, pinned to what this produces. + it("sends a journal refusal's reason where a client reads it", () => { + const response = mapRuntimeError( + 'req_1', + meta, + agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + ) + const wire = JSON.parse(JSON.stringify(response.error)) + expect(wire).toEqual({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + }) + expect(readAgentSessionErrorRefusal(wire)).toEqual({ + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + }) + it('exposes no code property another passthrough could claim', () => { expect( 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index 4d9f4a7706c..59273cf8715 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -1,3 +1,4 @@ +import { isAgentStatusHooksEnabledForAgent } from '../../../../shared/agent-status-hooks-setting' import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' import { defineMethod } from '../core' import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params' @@ -10,15 +11,13 @@ export const AGENT_HOOK_METHODS = [ if (clientKind !== undefined) { throw new Error('Codex hook preparation is only available to the local Orca CLI.') } - const settings = runtime.getClientSettings() return await prepareManagedWslCodexHomeBeforeShellLaunch({ env: { CODEX_HOME: params.codexHome, ORCA_CODEX_HOME: params.orcaCodexHome, WSL_DISTRO_NAME: params.wslDistro }, - hooksEnabled: - settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') + hooksEnabled: isAgentStatusHooksEnabledForAgent(runtime.getClientSettings(), 'codex') }) } }) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 8ef0f2c904d..2e99f580125 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -8,7 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' @@ -153,7 +153,7 @@ describe('a replayed launch', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-caller-')) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts index 22d025106f1..d1d419a4c56 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -11,7 +11,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -220,7 +221,7 @@ describe('a live-pane refusal under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-pane-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index 6b7adb8dbfc..cfc14ec5504 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -8,7 +8,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -55,7 +56,7 @@ describe('a launch whose terminal fails', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 336cd4c212b..bf90e650d53 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -24,8 +24,12 @@ import { settleAgentSessionOperation, type AgentSessionOperationRow } from '../../../../shared/agent-session-operation-ledger' -import { AgentSessionRecordStore } from '../../agent-session-record-store' -import { agentSessionStorePath } from '../../agent-session-record-store-file' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore, + testAgentSessionStoreFilePath +} from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' @@ -120,7 +124,7 @@ beforeEach(async () => { attachCallerKeys.length = 0 createStructuredSession.mockClear() directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-replay-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // The launch reaches the ledger through the installed host; nothing else on the host is used, // because the structured create below it is mocked out. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. @@ -241,7 +245,7 @@ describe('a replay answers from the record', () => { const params = createLaunch({ operationId: OPERATION_ID }) const first = await launch(params, runtime) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. setStructuredAgentSessionHost({ deps: { store: reopened } @@ -411,9 +415,8 @@ describe('the recorded row stays readable by a build that predates it', () => { runtimeStub({ createSupport: { supported: false, reason: 'agent' } }) ) - const file: { operations: Record }> } = JSON.parse( - await readFile(agentSessionStorePath(directory), 'utf-8') - ) + const file: { operations: Record }> } = + await readPersistedTestAgentSessionStore(directory) const outcome = Object.values(file.operations)[0].outcome // The ratchet, and the reason this is not a new status arm or an optional `sessionId`: a build @@ -431,7 +434,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => /** The whole file, primary and backup: `loadAgentSessionStore` falls through to the backup, and * the backup is a copy of the validated primary, so both carry the same payload in real life. */ async function rewriteRecordedLaunch(payload: unknown): Promise { - const path = agentSessionStorePath(directory) + const path = testAgentSessionStoreFilePath(directory) const file: { operations: Record }> } = JSON.parse( await readFile(path, 'utf-8') ) @@ -465,7 +468,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => await launch(params, runtime) await rewriteRecordedLaunch({ outcome: { kind: 'structured' }, worktreeId: 'wt-1' }) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. setStructuredAgentSessionHost({ deps: { store: reopened } diff --git a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts index f59e7c676bd..528490d1d0a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts @@ -10,7 +10,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -208,7 +209,7 @@ describe('a taken session id under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-session-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index d943775ef3b..a240c0c3919 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -25,6 +25,8 @@ import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams } from './ai-vault' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal' import { configureAiVaultSessionSources, listAiVaultSessions, @@ -221,6 +223,65 @@ describe('aiVault.prepareSessionResume', () => { }) }) +// Session history and terminal resume are not chats: a process whose chats are refused still +// serves them, while any other host failure still fails the request. +describe('aiVault methods without a structured host', () => { + const refusal = agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) + recordStructuredAgentSessionHostInstallRefusal(refusal) + }) + + afterEach(() => { + recordStructuredAgentSessionHostInstallRefusal(null) + resetAiVaultSessionListCacheForTests() + }) + + function refusedDispatcher(installError: Error): RpcDispatcher { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the two aiVault handlers read only these runtime members. + const runtime = { + getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn().mockRejectedValue(installError), + listAiVaultSessions: (args?: Parameters[0]) => + listAiVaultSessions(args), + prepareAiVaultSessionResume: vi.fn().mockResolvedValue({ useRealCodexHome: true }) + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + } + + it('lists and prepares a resume while chats are refused', async () => { + const dispatcher = refusedDispatcher(refusal) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ ok: true, result: makeResult() }) + await expect( + dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed' + }) + ) + ).resolves.toMatchObject({ ok: true, result: { useRealCodexHome: true } }) + }) + + it('still fails on a host error that refuses nothing', async () => { + const dispatcher = refusedDispatcher(new Error('the record store would not open')) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ ok: false }) + }) +}) + describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index b6a2165939a..b4169145902 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -17,6 +17,7 @@ import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions } from '../../../ai-vault/structured-session-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, @@ -66,7 +67,9 @@ export const AI_VAULT_METHODS = [ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, handler: async (params, { runtime, clientKind, clientCapabilities }) => { - await runtime.ensureStructuredAgentSessionHost() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) let result try { result = await runtime.listAiVaultSessions({ @@ -107,7 +110,9 @@ export const AI_VAULT_METHODS = [ // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID } - await runtime.ensureStructuredAgentSessionHost() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) assertLegacyAiVaultResumeAllowed(args) return runtime.prepareAiVaultSessionResume(args) } diff --git a/src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts b/src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts index 9d628182083..1e23163efe9 100644 --- a/src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts @@ -131,12 +131,6 @@ afterEach(() => { }) describe('chunk reads in flight on one connection', () => { - // Pinned as a literal because every other case here is written in terms of the constant, so the - // budget itself would otherwise move silently with it. - it('budgets four', () => { - expect(MAX_CONCURRENT_MOBILE_WEB_BUNDLE_READS).toBe(4) - }) - it('admits four and refuses the fifth, then admits it once one finishes', async () => { gate.hold() const inFlight = Array.from({ length: MAX_CONCURRENT_MOBILE_WEB_BUNDLE_READS }, () => diff --git a/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts b/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts new file mode 100644 index 00000000000..e6b3aae471e --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry' +import type { RpcContext } from '../core' + +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: vi.fn(), + subscribeNativeChatTranscript: () => Promise.resolve({ unsubscribe: () => {}, watching: true }) +})) + +import { NATIVE_CHAT_METHODS } from './native-chat' + +type Handler = ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void +) => Promise + +function handler(name: string): Handler { + const method = NATIVE_CHAT_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`${name} not registered`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both methods take (params, ctx[, emit]); the test only calls them with that shape. + return method.handler as Handler +} + +/** One phone connection on a runtime whose subscriptions live in the real registry. */ +function phoneConnection(): { + subscribe: (subscriptionId: string) => Promise + unsubscribe: (subscriptionId: string) => Promise +} { + const registry = new RuntimeSubscriptionRegistry() + const context: RpcContext = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test only touch these three registry-backed members. + runtime: { + registerSubscriptionCleanup: registry.register.bind(registry), + cleanupSubscription: registry.cleanup.bind(registry), + cleanupSubscriptionsByPrefix: registry.cleanupByPrefix.bind(registry) + } as unknown as RpcContext['runtime'], + connectionId: 'phone-connection', + clientKind: 'mobile' + } + return { + subscribe: async (subscriptionId) => { + const emitted: unknown[] = [] + await handler('nativeChat.subscribe')( + { agent: 'claude', sessionId: 'session', subscriptionId }, + context, + (value) => emitted.push(value) + ) + return emitted + }, + unsubscribe: async (subscriptionId) => { + await handler('nativeChat.unsubscribe')({ subscriptionId }, context, () => {}) + } + } +} + +describe('nativeChat subscription tokens on one connection', () => { + it('keeps two feeds of the same chat apart when each has its own token', async () => { + const phone = phoneConnection() + const under = await phone.subscribe('claude:session:under') + const top = await phone.subscribe('claude:session:top') + expect(under).toEqual([]) + expect(top).toEqual([]) + + await phone.unsubscribe('claude:session:top') + expect(top).toEqual([{ type: 'end' }]) + expect(under).toEqual([]) + }) + + it('ends the older feed when a second one reuses its token', async () => { + const phone = phoneConnection() + const older = await phone.subscribe('claude:session') + const newer = await phone.subscribe('claude:session') + expect(older).toEqual([{ type: 'end' }]) + expect(newer).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index dad93face82..f5684f1ba1b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -245,20 +246,23 @@ export async function sendStructuredWorkerPreamble(args: { throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) } // Accepted is not delivered: the worker's agent may still be starting. + const answered = agentSessionSendSubmission(result.value) const submission = - result.value.submission.dispatchState === 'pending' - ? (( - await args.host - .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS - }) - .catch(() => undefined) - )?.value.submission ?? result.value.submission) - : result.value.submission - if (submission.dispatchState === 'accepted' || submission.dispatchState === 'pending') { + answered?.dispatchState === 'pending' + ? (agentSessionSendSubmission( + ( + await args.host + .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value + ) ?? answered) + : answered + if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { return submission.dispatchState } - if (submission.dispatchState === 'rejected') { + if (submission?.dispatchState === 'rejected') { // A rejection is a verdict, not a mystery: the preamble provably did not happen. // `dispatch_preamble_undelivered` says exactly that, and says it as a code rather // than as prose, so a coordinator can tell "we could not send it" apart from @@ -277,7 +281,7 @@ export async function sendStructuredWorkerPreamble(args: { // `outcome_unknown` receipt whose nextCommands send the coordinator to look. throw new OrchestrationError( 'operation_unknown', - `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${reasonClause(submission.reason)}.` + `The dispatch preamble was submitted but not acknowledged (${submission?.dispatchState ?? 'unknown'}): ${reasonClause(submission?.reason)}.` ) } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index 199e43fae7b..9531e0f468b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -12,7 +12,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { @@ -29,6 +28,12 @@ const STRUCTURED_HANDLE = 'structworker_worker' const TERMINAL_HANDLE = 'term_worker' const structuredPreambles: string[] = [] +// The session host the code under test reads; a structural fake, so no host type is claimed. +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) + +vi.mock('../../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ ...(await importOriginal>()), @@ -70,7 +75,7 @@ function installStructuredCoordinator(handle: string, sessionId: string): string worktreeId: WORKTREE, hostScope: { kind: 'local', hostId: 'local' } }) - setStructuredAgentSessionHost({ + hostRef.current = { hasSession: () => true, deps: { store: { @@ -83,10 +88,12 @@ function installStructuredCoordinator(handle: string, sessionId: string): string deathEvidence: null, runtimeFence: 1 } - }) + }), + // No committed /clear: each session is its own lineage's root. + listRecords: () => [] } } - } as never) + } return paneKey } @@ -150,7 +157,7 @@ describe('a worker cannot tell which mode it is running in', () => { afterEach(() => { db.close() - setStructuredAgentSessionHost(null) + hostRef.current = null structuredWorkerIdentities.clear() vi.restoreAllMocks() }) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index c499f743576..8828c771876 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -2,11 +2,15 @@ import type { LegacyAdoptedMailboxOwner, OrchestrationDb } from '../../../../orc import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import { readStructuredAgentSessionRecord } from '../../../../structured-worker-authority' -import { structuredWorkerHostScope } from '../../../../structured-worker-identity' import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' import { isEquivalentPaneKey } from '../../../../orchestration/db/pane-key-match' import { CURRENT_CONTRACT_VERSION } from '../../../../orchestration/db/contract-constants' +import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-lineage' +import { + readSessionRecipient, + refuseUndeliverableSessionRecipient, + type SessionRecipientRefusal +} from './session-recipient' const ACTIVE_DISPATCH_STATUSES: readonly DispatchStatus[] = ['pending', 'dispatched'] @@ -150,7 +154,11 @@ export type BareRecipientResolution = } | { ok: false - code: 'terminal_not_found' | 'recipient_ambiguous' | 'recipient_run_mismatch' + code: + | 'terminal_not_found' + | 'recipient_ambiguous' + | 'recipient_run_mismatch' + | SessionRecipientRefusal['code'] message: string warning: SendRecipientWarning } @@ -164,7 +172,12 @@ export function resolveBareOrchestrationRecipient(params: { legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null }): BareRecipientResolution { const { runtime, db } = params - const party = resolveOrchestrationParty(params.handle, db) + const sessionStore = readAgentSessionRecordStore() + const session = readSessionRecipient(params.handle, sessionStore) + if (session && 'code' in session) { + return refused(params.handle, session) + } + const party = resolveOrchestrationParty(session?.address ?? params.handle, db) const handle = party.address const paneKey = party.terminalHandle === null @@ -208,6 +221,13 @@ export function resolveBareOrchestrationRecipient(params: { return mismatch ?? { ok: true, to: `run:${selectedRunId}`, runId: selectedRunId } } + if (session) { + const refusal = refuseUndeliverableSessionRecipient(session, sessionStore, db) + return refusal + ? refused(params.handle, refusal) + : { ok: true, to: handle, runId: params.senderRunId } + } + if (paneKey) { return { ok: true, @@ -221,19 +241,7 @@ export function resolveBareOrchestrationRecipient(params: { } } - const chatSessionId = party.terminalHandle === null ? party.orcaSessionId : null - if (chatSessionId !== null) { - const record = readStructuredAgentSessionRecord(chatSessionId) - // Unlike a terminal handle, a session address outlives its process, so its direct mail is durable. - if (record && structuredWorkerHostScope(record.location)) { - return { ok: true, to: handle, runId: params.senderRunId } - } - } - - const message = - chatSessionId !== null - ? `Agent session ${chatSessionId} does not run on this host and has no durable Run/Dispatch mailbox.` - : `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` + const message = `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` return { ok: false, code: 'terminal_not_found', @@ -242,6 +250,15 @@ export function resolveBareOrchestrationRecipient(params: { } } +function refused(recipient: string, refusal: SessionRecipientRefusal): BareRecipientResolution { + return { + ok: false, + code: refusal.code, + message: refusal.message, + warning: { code: 'recipient_unreachable', recipient, message: refusal.message } + } +} + function selectDispatch( dispatches: DispatchContextRow[], explicitRunId: string | undefined diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 527c8c6a509..58dcceb5598 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -17,6 +17,7 @@ import { } from '../../../orchestration-mutation-executor' import { replayMutationNudge } from './mutation-replay-nudge' import { sendRemoteMessage } from './send-remote' +import { mayNameSession } from './session-recipient' import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' @@ -132,6 +133,10 @@ export const ORCHESTRATION_SEND_METHODS = [ const sendWarnings: SendRecipientWarning[] = [] let messageRunId = routing.run?.id if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + if (mayNameSession(to)) { + // Recipient routing reads the session record store, which the host opens lazily. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + } const recipient = resolveBareOrchestrationRecipient({ runtime, db, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts new file mode 100644 index 00000000000..a22975c0454 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -0,0 +1,118 @@ +/** + * An agent session named as a recipient: `session:`, or a bare Orca session id. Any session on + * this host can be addressed, not only one that coordinates a Run: an agent's id is its public + * address, and a user telling one agent to message another's id is a supported workflow. + * + * Mail that no Run or Dispatch owns is stored at the conversation's `session:` and pointed + * at its live session as a turn, so any session of a `/clear` lineage is a valid spelling. A + * released lease is not a refusal (the pointer's send starts its agent); a closed chat, another host, + * and an unknown id are, before anything is stored. + */ + +import { + ORCA_SESSION_ADDRESS_PREFIX, + formatOrcaSessionAddress, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionAddress, + type OrcaSessionId +} from '../../../../../../shared/orca-session-address' +// The caller codes, reused: each names the same fact about a session, whichever side of the mail it is on. +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-caller-codes' +import { + lookupOrcaAgentSession, + structuredSessionMailReach +} from '../../../../orchestration/structured-session-mail-address' +import type { AgentSessionRecordReader } from '../../../../orchestration/structured-session-lineage' +import type { OrchestrationDb } from '../../../../orchestration/db' + +/** `address` is the named session's own spelling; the mailbox mail lands in is its identity address. */ +export type SessionRecipient = { sessionId: OrcaSessionId; address: OrcaSessionAddress } + +export type SessionRecipientRefusal = { + code: (typeof CODES)[keyof typeof CODES] + message: string +} + +/** Whether a recipient may name a session, so the caller can install the session host first. */ +export function mayNameSession(recipient: string): boolean { + return recipient.startsWith(ORCA_SESSION_ADDRESS_PREFIX) || isOrcaSessionId(recipient) +} + +/** + * The session a recipient names. A bare string names a session only when it is an Orca session id + * this host has a record for; anything else stays a terminal handle, exactly as before. + */ +export function readSessionRecipient( + recipient: string, + store: AgentSessionRecordReader | null +): SessionRecipient | SessionRecipientRefusal | null { + if (recipient.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + const sessionId = parseOrcaSessionAddress(recipient) + return sessionId + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } + : { + code: CODES.unknown, + message: `${recipient} does not name an Orca agent session id. No message was sent.` + } + } + const sessionId = isOrcaSessionId(recipient) ? recipient : null + const found = sessionId && store ? lookupOrcaAgentSession(store, sessionId) : null + if (found?.kind === 'provider-id') { + return providerIdRefusal(recipient, found.orcaSessionId) + } + return sessionId && found?.kind === 'found' + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } + : null +} + +/** Null when mail to this session can be stored and delivered here; otherwise why not. */ +export function refuseUndeliverableSessionRecipient( + recipient: SessionRecipient, + store: AgentSessionRecordReader | null, + db: OrchestrationDb +): SessionRecipientRefusal | null { + const { sessionId } = recipient + if (!store) { + return { + code: CODES.unknown, + message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No message was sent.` + } + } + const found = lookupOrcaAgentSession(store, sessionId) + if (found.kind === 'provider-id') { + return providerIdRefusal(sessionId, found.orcaSessionId) + } + if (found.kind === 'unknown') { + return { + code: CODES.unknown, + message: `No Orca agent session ${sessionId} exists on this host. No message was sent.` + } + } + const reach = structuredSessionMailReach(store, found.record, db) + if (reach.kind === 'other-host') { + return { + code: CODES.hostBoundary, + message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. No message was sent.` + } + } + if (reach.kind === 'ended') { + return { + code: CODES.notLive, + message: + reach.reason === 'continuation-missing' + ? `Agent session ${sessionId} was cleared, and this host has no record of the session that continues it. No message was sent.` + : reach.reason === 'worker-identity-lost' + ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. No message was sent.` + : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` + } + } + return null +} + +function providerIdRefusal(id: string, orcaSessionId: string): SessionRecipientRefusal { + return { + code: CODES.providerId, + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca address is ${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}; use that instead. No message was sent.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index cef9e7dd8c1..e939d8a05f7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -1,4 +1,3 @@ -import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' const { ipcHandlers } = vi.hoisted(() => ({ @@ -31,10 +30,6 @@ vi.mock('@electron-toolkit/utils', () => ({ })) import type Database from '../../../../../sqlite/sync-database' -import { - scanSourceTree, - stripComments -} from '../../../../../../shared/source-scan/source-tree-scan' import type { AgentStatusIpcPayload } from '../../../../../../shared/agent-status-ipc-payload' import { toAgentStatusIpcPayload } from '../../../../../agent-hooks/server/server-status-identity' import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' @@ -47,119 +42,6 @@ import { OrcaRuntimeService } from '../../../../orca-runtime' import { ORCHESTRATION_WORKER_LIST_METHOD } from './worker-list-method' import { projectFleetWorkerPage } from './worker-observation' -/** - * Census of every production site in `src/main` that turns hook-server agent-status rows into - * something a consumer reads. - * - * Why a census and not a single seam test: the false-liveness bug (rework failure table L-1) was - * one such site publishing rows that carry a pane key and nothing else, into a consumer that - * matches on terminal identity. Fixing that site fixes nothing if a fifth one is added beside it, - * so the list is pinned and the identity-bearing paths are each driven end to end. - */ -type CensusRow = { - path: string - /** `produces` = mints payloads a consumer reads; `consumes` = reads them; `wiring` = neither. */ - kind: 'produces' | 'consumes' | 'wiring' - role: string -} - -const CENSUS: readonly CensusRow[] = [ - { - path: 'main/ipc/agent-hooks.ts', - kind: 'produces', - role: 'agentStatus:getSnapshot — renderer pull, enriched (driven below)' - }, - { - path: 'main/ipc/agent-status-ipc-boundary.ts', - kind: 'produces', - role: 'resolveAgentStatusBinding — the one identity lookup the pull and fleet paths share' - }, - { - path: 'main/runtime/agent-status-observed-pane-identity.ts', - kind: 'produces', - role: 'captures the identity a hook row was observed under (fleet-status-observed-identity)' - }, - { - path: 'main/runtime/orchestration-fleet-agent-status-snapshot.ts', - kind: 'produces', - role: 'readOrchestrationFleetAgentStatusSnapshot — the minted fleet evidence (driven below)' - }, - { - path: 'main/startup/main-window-agent-status.ts', - kind: 'produces', - role: 'agentStatus:set — renderer live push, enriched inline (driven below)' - }, - { - path: 'main/startup/main-process-runtime-service.ts', - kind: 'wiring', - role: 'binds the hook server snapshot into the runtime deps' - }, - { - path: 'main/orcad/orcad-entry.ts', - kind: 'wiring', - role: 'binds the same snapshot, OSC producer and structured sink into the headless orcad runtime deps' - }, - { - path: 'main/runtime/orca-runtime-state-fields.ts', - kind: 'wiring', - role: 'stores the snapshot deps on the runtime' - }, - { - path: 'main/runtime/orca-runtime-preserved-branch-cleanup.ts', - kind: 'wiring', - role: 'declares the snapshot dep fields' - }, - { - path: 'main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts', - kind: 'produces', - role: 'getOrchestrationFleetAgentStatusSnapshot — delegates to the checked snapshot module' - }, - { - path: 'main/runtime/orca-runtime-stop-requested-pty-ids.ts', - kind: 'wiring', - role: 'feeds the enriched fleet rows to the orchestration projection' - }, - { - path: 'main/runtime/runtime-agent-orchestration-projection.ts', - kind: 'consumes', - role: 'indexes rows by pane key to attach dispatch context' - }, - { - path: 'main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts', - kind: 'consumes', - role: 'worker-list fleet verdict (driven below)' - }, - { - path: 'main/runtime/rpc/methods/orchestration/worker/worker-observation.ts', - kind: 'consumes', - role: 'worker-show fleet verdict (driven below)' - }, - { - path: 'main/runtime/orca-runtime-get-worktree-ps.ts', - kind: 'consumes', - role: 'worktree.ps inline agent rows (driven below)' - }, - { - path: 'main/runtime/orca-runtime-get-terminal-interactive-wait.ts', - kind: 'consumes', - role: 'exact-worker provider session selection, matched on pane key' - }, - { - path: 'main/runtime/orca-runtime-serialize-agent-prompt-submission.ts', - kind: 'consumes', - role: 'prompt-submission serialization, matched on pane key' - }, - { - path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', - kind: 'consumes', - role: 'mobile tab-group pruning and its live agent row, plus the pane identity accessors' - } -] - -/** The names a hook row travels under. A new producer has to use one of them to reach a consumer. */ -const PRODUCER_TOKENS = - /getAgentStatusSnapshot|getAgentProviderSessionSnapshot|enrichAgentStatusIpcPayload|mintAgentStatusFleetEvidence|resolveAgentStatusBinding|getOrchestrationFleetAgentStatusSnapshot|agentStatus:set/ - const PANE_KEY = 'tab-census:leaf-census' const TERMINAL_HANDLE = 'term_census' const PROCESS_INCARNATION = 'pty-census:inc-1' @@ -258,17 +140,7 @@ function censusStore() { } } -describe('agent status producer census', () => { - it('pins every production site that hands hook rows to a consumer', () => { - const root = resolve(import.meta.dirname, '../../../../../..') - const scanned = scanSourceTree(resolve(root, 'main')) - .filter((file) => PRODUCER_TOKENS.test(stripComments(file.source))) - .map((file) => `main/${file.relativePath}`) - .sort() - - expect(scanned).toEqual(CENSUS.map((row) => row.path).sort()) - }) - +describe('agent status identity across every producer and consumer path', () => { it('reads live on worker-list from a hook row that carries only a pane key', async () => { const db = new OrchestrationDb(':memory:') try { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index a2b049e4190..90deacab5c3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' @@ -12,6 +12,7 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -175,15 +176,12 @@ describe('committed adopting create RPC replay', () => { .mockRejectedValueOnce(new Error('simulated lost tab publication')) .mockResolvedValue(undefined) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ store, adapter: sessionAdapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1' }) setStructuredAgentSessionHost(host) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 1cc5050d803..9cbad804cd5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session-journal-types' // A chat at rest, through the RPC surface a client actually calls: opening it starts nothing, what // it can answer without an agent it answers, and the first send is what starts one. @@ -5,13 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' -import { openJournalDatabase } from '../../../native-chat/agent-session-journal/journal-database' import { - journalDatabaseFile, - journalDirectoryFor -} from '../../../native-chat/agent-session-journal/journal-paths' -import { - HOST_TEST_LOCATION, hostTestAttachParams, hostTestMessage, hostTestOperationId @@ -33,6 +28,10 @@ import { RpcDispatcher } from '../dispatcher' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const CLIENT = { clientId: 'device-1', @@ -158,7 +157,8 @@ describe('the accessor', () => { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - hostTestMessage('from the provider') + hostTestMessage('from the provider'), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) await rig.restart() @@ -311,17 +311,8 @@ describe('the accessor', () => { it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() - const directory = journalDirectoryFor(rig.root, { - workspaceId: HOST_TEST_LOCATION.workspaceId, - sessionId: SESSION - }) // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. - const opened = openJournalDatabase(journalDatabaseFile(directory)) - try { - opened.db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) - } finally { - opened.db.close() - } + updateTestJournalRowJson(openTestJournalHostDatabase(rig.root).db, SESSION, 2, '}{') await rig.restart() setStructuredAgentSessionHost(rig.host) @@ -421,7 +412,8 @@ describe('an agent exit', () => { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) // The exit's own write and the retry recording the exit queues are both refused. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 7b388f5eca2..a2876f284db 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -56,6 +56,15 @@ export const ADMISSION_METHODS = [ }, { method: 'agentSession.ensure', params: attachParams() }, { method: 'agentSession.send', params: sendParams() }, + { + method: 'agentSession.queuedMessageSend', + params: { envelope: envelope(), messageId: 'queued-1' } + }, + { + method: 'agentSession.queuedMessageDelete', + params: { envelope: envelope(), messageId: 'queued-1' } + }, + { method: 'agentSession.queuedMessagesResume', params: { envelope: envelope() } }, { method: 'agentSession.rewind', params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index e4ff861d1ac..0bed563af38 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -13,6 +13,7 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' +import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { supportsStructuredAgentSessionCapability, supportsStructuredAgentSessions @@ -36,13 +37,7 @@ export function requireStructuredCapability(ctx: RpcContext): void { export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { requireStructuredCapability(ctx) - const host = getStructuredAgentSessionHost() - if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostDisabled' - }) - } - return host + return requireHostOrRefusal() } /** @@ -73,13 +68,22 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe reason: 'clientCapabilityMissing' }) } + return requireHostOrRefusal() +} + +/** + * The host, or why there is none. A process whose journal would not open says so under every + * getter — cleanup included: nothing here can stop a child it never started. + */ +function requireHostOrRefusal(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() - if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostDisabled' - }) + if (host) { + return host } - return host + throw ( + structuredAgentSessionHostRefusal() ?? + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ) } /** Builds the host for a call that may be the first this process sees. Every session is addressed diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 140be5e1c39..000192cc183 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -19,13 +19,15 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const CONNECTION = 'connection-1' const CLIENT = { @@ -74,7 +76,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { @@ -93,7 +95,7 @@ beforeEach(async () => { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts new file mode 100644 index 00000000000..a4b68ccbcd3 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -0,0 +1,31 @@ +// The queued-message actions: Send-now and Delete on one card, and Resume on a +// paused queue. All gated on agent-session.queued-messages.v1; an older host +// lacks the methods entirely. + +import { defineMethod } from '../core' +import { + requireStructuredHost as requireHost, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import { + QueuedMessageActionParams, + QueuedMessagesResumeParams +} from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ + defineMethod({ + name: 'agentSession.queuedMessageSend', + params: QueuedMessageActionParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.queuedMessageDelete', + params: QueuedMessageActionParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.queuedMessagesResume', + params: QueuedMessagesResumeParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 9a0f3902496..85d3e2a5b4b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -191,6 +191,7 @@ export function hostStub(): StructuredAgentSessionHost { current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), + sessionAgent: vi.fn(() => null), journalSnapshot: vi.fn((sessionId: string) => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 345fdf5bf47..5930fa1e80f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -16,6 +16,8 @@ export { ModelCatalogParams, MutationEnvelope, OptionsParams, + QueuedMessageActionParams, + QueuedMessagesResumeParams, RespondParams, RespondToQuestionParams, RestartResumableParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 9ae846df6fc..59bc914558e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -34,11 +34,17 @@ describe('agentSession.send reply timing', () => { await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { - until: 'handed-over', + until: 'handed-over-or-behind-command', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS }) }) + it("marks a client's send as the user's own, which alone lifts a Stop's queue pause", async () => { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(hostCalls.send.mock.calls[0]?.[1]).toMatchObject({ userSend: true }) + }) + it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => { for (const clientCapabilities of [ DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 5fde5e0f6e4..47afaa8ac73 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' @@ -9,19 +10,23 @@ import { requireStructuredHost, structuredCallerFor } from './structured-agent-s /** * A send answers once the host accepts it. A client that predates that answer cannot show a - * message rejected after it, so its reply is held until the message is handed over or rejected; - * one that predates pending replies at all waits, as before, for the provider's answer. + * message rejected after it, so its reply is held until the message is handed over or rejected — + * or queued behind a running command such as `/compact`, which could outlast the client's own + * request timeout; one that predates pending replies at all waits, as before, for the provider's + * answer. */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], context: RpcContext ) { const host = requireStructuredHost(context) - const result = await host.send(structuredCallerFor(context), params) + // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. + const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) const capabilities = context.clientCapabilities ?? [] if ( !result.ok || - result.value.submission.dispatchState !== 'pending' || + // A queued answer only ever reaches a capable client, which renders it as-is. + agentSessionSendSubmission(result.value)?.dispatchState !== 'pending' || context.clientKind === undefined || capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ) { @@ -33,7 +38,7 @@ export async function sendStructuredAgentSessionForClient( result.value.clientMessageId, { until: capabilities.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) - ? 'handed-over' + ? 'handed-over-or-behind-command' : 'answered', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, ...(context.signal ? { signal: context.signal } : {}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts index f0694811a9a..c6984552399 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts @@ -24,7 +24,10 @@ function readsTurnItems(ctx: TurnItemReader): boolean { ) } -function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] { +function projectItems( + items: AgentJournalRenderItem[], + sessionAgent: string | null +): AgentJournalRenderItem[] { if (!items.some((item) => item.body.kind === 'turn')) { return items } @@ -33,39 +36,45 @@ function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] return item } const { kind: _kind, ...turn } = item.body - return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId) } + return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId, sessionAgent) } }) } -function projectPage(page: AgentSessionHistoryPage): AgentSessionHistoryPage { - const items = projectItems(page.items) +function projectPage( + page: AgentSessionHistoryPage, + sessionAgent: string | null +): AgentSessionHistoryPage { + const items = projectItems(page.items, sessionAgent) return items === page.items ? page : { ...page, items } } +/** `sessionAgent` names the agent on a turn whose key does not, such as a command's. */ export function projectTurnItemHistory( result: AgentSessionHistoryResult, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionHistoryResult { if (readsTurnItems(ctx)) { return result } - const page = projectPage(result.page) + const page = projectPage(result.page, sessionAgent) return page === result.page ? result : { ...result, page } } export function projectTurnItemEvent( event: AgentSessionSubscribeEvent, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionSubscribeEvent { if (readsTurnItems(ctx)) { return event } if (event.type === 'batch') { - const items = projectItems(event.batch.items) + const items = projectItems(event.batch.items, sessionAgent) return items === event.batch.items ? event : { ...event, batch: { ...event.batch, items } } } if (event.type === 'snapshot' || event.type === 'reset') { - const page = projectPage(event.page) + const page = projectPage(event.page, sessionAgent) return page === event.page ? event : { ...event, page } } return event diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index a8378489c02..d9d430f08e3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -153,6 +154,8 @@ describe('capability gating', () => { expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) // A client tells a host that accepts first, and admits a writer-free Stop before a turn, by it. expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + // And a cancel naming no turn, which a host that only accepts first still refuses as invalid. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY) // Separate from the structured capability on purpose: a host can serve the rest of the @@ -171,7 +174,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(29) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index dc01a2fee8f..534d4967779 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -34,6 +34,7 @@ import { } from './structured-agent-session-create' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' +import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { @@ -206,6 +207,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CancelParams, handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), + ...STRUCTURED_AGENT_SESSION_QUEUED_METHODS, defineMethod({ // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the // same session can be attached again. Only the provider child and the in-memory entry go. @@ -254,11 +256,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.history', params: HistoryParams, - handler: async (params, ctx) => - projectTurnItemHistory( - projectBackgroundTaskHistory(await (await requireInstalledHost(ctx)).history(params), ctx), - ctx + handler: async (params, ctx) => { + const host = await requireInstalledHost(ctx) + return projectTurnItemHistory( + projectBackgroundTaskHistory(await host.history(params), ctx), + ctx, + host.sessionAgent(params.sessionId) ) + } }), defineStreamingMethod({ name: 'agentSession.subscribe', @@ -277,7 +282,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), + emit: (event) => + emit( + projectTurnItemEvent( + projectBackgroundTaskEvent(event, ctx), + ctx, + host.sessionAgent(params.sessionId) + ) + ), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f76b3ae6cb7..29623f623cd 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -24,8 +24,11 @@ import { resetHostTestOperationIds } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { AgentSessionRecordStore } from '../../agent-session-record-store' -import { agentSessionStorePath } from '../../agent-session-record-store-file' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { + openTestAgentSessionRecordStore, + testAgentSessionStoreFilePath +} from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' @@ -33,6 +36,7 @@ import { SESSION_TAB_METHODS } from './session-tabs' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -86,14 +90,11 @@ function providerAdapter(): StructuredAgentSessionAdapter { } async function openHost(): Promise { - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ store, adapter: providerAdapter(), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, mintSpawnToken: () => `spawn-${acquisitions}` @@ -293,7 +294,7 @@ describe('a chat tab across /clear', () => { await host.flushAllStreamedEvents() // An older build rewrites the file from what it read, which drops the table. - const file = agentSessionStorePath(join(directory, 'store')) + const file = testAgentSessionStoreFilePath(directory) const raw = JSON.parse(await readFile(file, 'utf-8')) expect(raw.sessionTabs).toHaveLength(2) delete raw.sessionTabs diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts index 59370934057..b5e7df475bb 100644 --- a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -48,7 +48,8 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ ['terminal.subscribe', { terminal: 'term' }, true], ['terminal.unsubscribe', { subscriptionId: 'term' }, false], ['terminal.getAutoRestoreFit', {}, false], - ['terminal.setAutoRestoreFit', { ms: null }, false] + ['terminal.setAutoRestoreFit', { ms: null }, false], + ['terminal.setViewerColors', { colors: { foreground: '#ffffff', background: '#000000' } }, false] ] function schemaFor(name: string) { @@ -68,11 +69,11 @@ async function invoke(name: string, params: unknown, runtime: Partial { it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => { - expect(TERMINAL_METHODS).toHaveLength(36) + expect(TERMINAL_METHODS).toHaveLength(37) expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual( METHOD_CASES.map(([name, _params, stream]) => [name, stream]) ) - expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(36) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(37) for (const [name, params] of METHOD_CASES) { expect(() => schemaFor(name).parse(params), name).not.toThrow() } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 607a29329cd..7c5e21abead 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -7,6 +7,7 @@ import { TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS } from './terminal/terminal-viewport-methods' +import { TERMINAL_VIEWER_COLORS_METHODS } from './terminal/terminal-viewer-colors-method' // The manifest order is part of the released RPC contract. Keep composition here so the // public entry point owns registration rather than forwarding an aggregated child export. @@ -17,5 +18,6 @@ export const TERMINAL_METHODS = [ ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS, ...TERMINAL_MULTIPLEX_METHODS, ...TERMINAL_SUBSCRIBE_METHODS, - ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS + ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, + ...TERMINAL_VIEWER_COLORS_METHODS ] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts new file mode 100644 index 00000000000..9b0d5b4fc18 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { RpcDispatcher } from '../../dispatcher' +import { TERMINAL_METHODS } from '../terminal' +import { + _resetTerminalViewAttributesForTest, + getTerminalViewerColors, + setTerminalViewerColorsListener +} from '../../../terminal-view-attribute-store' + +function dispatcher(): RpcDispatcher { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the method reads nothing from the runtime; the dispatcher needs only its id. + const runtime = { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) +} + +function setViewerColors(params: unknown) { + return dispatcher().dispatch({ + id: 'viewer-colors-1', + authToken: 'token', + method: 'terminal.setViewerColors', + params + }) +} + +describe('terminal.setViewerColors', () => { + afterEach(() => _resetTerminalViewAttributesForTest()) + + it("makes a paired client's colours the ones a headless host answers with", async () => { + const published: unknown[] = [] + setTerminalViewerColorsListener((colors) => published.push(colors)) + const colors = { foreground: '#2e3434', background: '#ffffff' } + + const response = await setViewerColors({ colors }) + + expect(response).toMatchObject({ ok: true, result: { applied: true } }) + expect(getTerminalViewerColors()).toEqual(colors) + expect(published).toEqual([colors]) + }) + + it('keeps the current colours when the pair cannot answer both slots', async () => { + const response = await setViewerColors({ colors: { foreground: '#ffffff' } }) + + expect(response).toMatchObject({ ok: true, result: { applied: false } }) + expect(getTerminalViewerColors()).toBeNull() + }) + + it('ignores members a newer client adds', async () => { + const colors = { foreground: '#000000', background: '#ffffff' } + + const response = await setViewerColors({ colors: { ...colors, cursor: '#ff0000' }, seq: 3 }) + + expect(response).toMatchObject({ ok: true, result: { applied: true } }) + expect(getTerminalViewerColors()).toEqual(colors) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts new file mode 100644 index 00000000000..c2c94efee6d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.ts @@ -0,0 +1,20 @@ +import { defineMethod } from '../../core' +import { normalizeColorQueryReplyColors } from '../../../../../shared/pty-owner-color-query-colors' +import { setPairedViewerColors } from '../../../terminal-view-attribute-store' +import { TerminalSetViewerColors } from './unary-schemas' + +export const TERMINAL_VIEWER_COLORS_METHODS = [ + defineMethod({ + name: 'terminal.setViewerColors', + params: TerminalSetViewerColors, + // Why: a headless host has no theme of its own to answer OSC 10/11 with, so it answers with + // the colours of the paired client that last pushed; a host with its own window keeps its own. + handler: async (params) => { + const colors = normalizeColorQueryReplyColors(params.colors) + if (colors) { + setPairedViewerColors(colors) + } + return { applied: colors !== null } + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts index 0b7f9d90408..65787c8c405 100644 --- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -13,6 +13,7 @@ export { TerminalResolveActive, TerminalResolvePane, TerminalSend, + TerminalSetViewerColors, TerminalSleep, TerminalSplit, TerminalStop, diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index a8fe8ca00c7..5f077b9d204 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -9,6 +9,7 @@ import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-sche // strict schema is deliberate — but it must stay deliberate rather than // forgotten, which is what the parity assertion below enforces. type MainOwnedUIState = + | '_explorerDisplayRootMigrated' | 'trayMinimizeNoticeShown' | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index d96e214d442..d6ab1ba3ccb 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -376,6 +376,80 @@ describe('orchestration session callers at the dispatch entry', () => { expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) }) + it('accepts a /clear successor naming the address it had before the clear, and no other', async () => { + // Y continued X after a /clear, so X's address is Y's: the lineage root names the chat. + h.records.set(SESSION_X, { + ...sessionRecord(SESSION_X), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: SESSION_Y, + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + }) + const run = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: ADDRESS_X }, + { sessionId: SESSION_Y } + ) + ) + ).run + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBe(SESSION_X) + + const stranger = 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' + const refused = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: stranger }, + { sessionId: SESSION_Y } + ) + ) + expect(refused).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + }) + + it.each(['orchestration.gateList', 'orchestration.taskList'])( + 'checks a caller %s names beside --run: the /clear root is the chat, a stranger is refused', + async (method) => { + h.records.set(SESSION_X, { + ...sessionRecord(SESSION_X), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: SESSION_Y, + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + }) + const runId = idOf( + resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: SESSION_Y } + ) + ) + ).run + ) + const param = ORCHESTRATION_CALLER_PARAM[method]! + const listed = await h.dispatch( + orchestrationRequest(method, { run: runId, [param]: ADDRESS_X }, { sessionId: SESSION_Y }) + ) + expect(resultOf(listed)).toMatchObject({ runId }) + + const stranger = 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' + const refused = await h.dispatch( + orchestrationRequest(method, { run: runId, [param]: stranger }, { sessionId: SESSION_Y }) + ) + expect(refused).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + } + ) + it.each([ADDRESS_X, SESSION_X])('accepts the session named as %s', async (declared) => { const run = resultOf( await h.dispatch( diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index e4bd09f2619..0d2253481b4 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -19,7 +19,6 @@ import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lea import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isOrcaSessionId, parseOrcaSessionAddress } from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' -import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../orca-runtime' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import { OrchestrationError } from '../orchestration/orchestration-error' @@ -30,6 +29,8 @@ import { resolveOrcaSessionParty, resolveOrchestrationParty } from '../orchestration/orchestration-party' +import { lookupOrcaAgentSession } from '../orchestration/structured-session-mail-address' +import { readAgentSessionRecordStore } from '../orchestration/structured-session-lineage' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { RpcRequest } from './core' @@ -149,10 +150,10 @@ async function readSessionRecord( runtime: OrcaRuntimeService, sessionId: string ): Promise { - let store: ReturnType + let store: ReturnType try { await runtime.ensureStructuredAgentSessionHost() - store = sessionRecordStore() + store = readAgentSessionRecordStore() } catch { store = null } @@ -163,16 +164,15 @@ async function readSessionRecord( NO_EFFECTS ) } - const record = store.getRecord(sessionId) - if (record) { - return record + const found = lookupOrcaAgentSession(store, sessionId) + if (found.kind === 'found') { + return found.record } - const owner = store.listRecords().find((candidate) => namesProviderSession(candidate, sessionId)) - if (owner) { + if (found.kind === 'provider-id') { throw new OrchestrationError( CODES.providerId, - `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${owner.sessionId}; use that instead. No effects were applied.`, - { ...NO_EFFECTS, orcaSessionId: owner.sessionId } + `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${found.orcaSessionId}; use that instead. No effects were applied.`, + { ...NO_EFFECTS, orcaSessionId: found.orcaSessionId } ) } throw new OrchestrationError( @@ -182,19 +182,6 @@ async function readSessionRecord( ) } -function sessionRecordStore(): { - getRecord: (sessionId: string) => AgentSessionRecord | null - listRecords: () => AgentSessionRecord[] -} | null { - return getStructuredAgentSessionHost()?.deps.store ?? null -} - -function namesProviderSession(record: AgentSessionRecord, id: string): boolean { - return record.providerHandleChain.some(({ handle }) => - handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id - ) -} - function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): void { if (!structuredWorkerHostScope(record.location)) { throw hostBoundary( diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index bbf199e7f50..f8f2de266d4 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -9,9 +9,11 @@ import { ADDRESS_X, ADDRESS_Y, createSessionCallerHarness, - orchestrationRequest, idOf, + isRecord, + orchestrationRequest, resultOf, + PROVIDER_ID_X, SESSION_X, SESSION_Y, sessionRecord, @@ -26,6 +28,198 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' type Row = Record +describe('a send addressed to an agent session', () => { + let h: SessionCallerHarness + let visible: string[] + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + visible = [SESSION_X, SESSION_Y] + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, + listRecords: () => [...h.records.values()], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible }) + } + } + } + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function send(to: string): Promise { + const response: unknown = await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + if (!isRecord(response)) { + throw new Error('expected an RPC response object') + } + return response + } + + function errorMessage(response: Row): string { + return isRecord(response.error) ? String(response.error.message) : '' + } + + it('stores mail to a live session that coordinates nothing at its own address, and points it', async () => { + // The refusal this replaces: "Terminal session: has no live pane or durable Run/Dispatch + // mailbox." An agent's id is its public address, coordinator or not. + const deliver = vi.spyOn(h.runtime, 'deliverPendingMessagesForHandle') + const sent = await send(ADDRESS_X) + expect(sent).toMatchObject({ ok: true, result: { message: { to_handle: ADDRESS_X } } }) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(ADDRESS_X, expect.anything())) + }) + + it('accepts a bare Orca session id and normalizes it', async () => { + expect(await send(SESSION_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: ADDRESS_X } } + }) + }) + + it('keeps routing a coordinating session to its Run mailbox', async () => { + const created = await h.dispatch( + orchestrationRequest('orchestration.runCreate', { objective: 'o' }, { sessionId: SESSION_X }) + ) + const runId = idOf(resultOf(created).run) + expect(await send(ADDRESS_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: `run:${runId}` } } + }) + }) + + it.each([ + [ + 'an unknown session', + () => `session:0b0b0b0b-1111-4222-8333-444444444444`, + 'session_caller_unknown' + ], + ['a malformed session address', () => 'session:term_abc', 'session_caller_unknown'], + ['a provider id', () => `session:${PROVIDER_ID_X}`, 'session_caller_provider_id'], + ['a bare provider id', () => PROVIDER_ID_X, 'session_caller_provider_id'] + ])('refuses %s before storing anything', async (_label, to, code) => { + const sent = await send(to()) + expect(sent).toMatchObject({ ok: false, error: { code } }) + expect(h.db.getInbox(100)).toEqual([]) + }) + + it('refuses a session on another host', async () => { + h.records.set(SESSION_Y, sessionRecord(SESSION_Y, { location: { executionHostId: 'ssh:box' } })) + expect(await send(`session:${SESSION_Y}`)).toMatchObject({ + ok: false, + error: { code: 'session_caller_host_boundary' } + }) + expect(h.db.getInbox(100)).toEqual([]) + }) + + it('refuses a session whose chat was closed, naming why', async () => { + visible = [SESSION_X] + const sent = await send(`session:${SESSION_Y}`) + expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(errorMessage(sent)).toContain('its chat was closed') + expect(h.db.getInbox(100)).toEqual([]) + }) + + it('refuses a structured worker whose worker identity is gone: nothing could ever read it', async () => { + // A Dispatch recorded the session as a worker; no registry entry or custody row maps it now. + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SESSION_Y), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + structuredWorkerIdentities.clear() + const sent = await send(`session:${SESSION_Y}`) + expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(errorMessage(sent)).toContain('worker identity') + }) + + it('leaves a bare string that is no session a terminal handle, as before', async () => { + expect(await send('0b0b0b0b-1111-4222-8333-444444444444')).toMatchObject({ + ok: false, + error: { code: 'terminal_not_found' } + }) + }) +}) + +describe('a live structured worker addressed by its session id', () => { + let h: SessionCallerHarness + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function sendTo(to: string): Promise { + return resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + ) + } + + async function flaglessCheck(): Promise { + return resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: SESSION_Y }) + ) + ) + } + + it('lands in its Dispatch mailbox, which its flagless check reads', async () => { + // The defect this pins: the mail was stored at `session:`, pointed at the worker, and its + // `check` — which reads the worker's handle and Dispatch mailboxes — returned nothing. + const run = h.db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:12121212-1212-4212-8212-121212121212' + }) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(await sendTo(`session:${SESSION_Y}`)).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}` } + }) + expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) + }) + + it('lands in its own handle mailbox between Dispatches, which its flagless check reads', async () => { + expect(await sendTo(SESSION_Y)).toMatchObject({ message: { to_handle: handle } }) + expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) + }) +}) + describe('mail sent to a session address reaches the mailbox that session reads', () => { let h: SessionCallerHarness @@ -70,18 +264,35 @@ describe('mail sent to a session address reaches the mailbox that session reads' }) }) - it('refuses an Orca session this host does not run', async () => { + it('reaches a chat with no Run after a restart, once the send has started the session host', async () => { + // After an app restart the agent-session host starts lazily; routing reads the session record + // synchronously, so the send must start the host before it resolves the recipient. + const store = hostRef.current + hostRef.current = null + vi.mocked(h.runtime.ensureStructuredAgentSessionHost).mockImplementation(async () => { + hostRef.current = store + }) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + }) + + it('refuses an Orca session this host does not run, or has no record of', async () => { h.records.set( SESSION_X, sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) ) h.records.delete(SESSION_Y) - for (const to of [ADDRESS_X, ADDRESS_Y]) { + for (const [to, code] of [ + [ADDRESS_X, 'session_caller_host_boundary'], + [ADDRESS_Y, 'session_caller_unknown'] + ]) { const response = await h.dispatch( orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: 's' }) ) - expect(response).toMatchObject({ ok: false, error: { code: 'terminal_not_found' } }) + expect(response).toMatchObject({ ok: false, error: { code } }) } }) diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index a5b40b2f1eb..9a8cc77af3e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -284,7 +284,6 @@ describe('terminal multiplex RPC', () => { )! ) } - expect(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION).toBe(128) for ( let streamId = 1; streamId <= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 1; diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts index 60c4c86fe44..1c0f08cf33f 100644 --- a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts +++ b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts @@ -5,7 +5,6 @@ import { terminalStreamByteLength, terminalStreamByteLengthExceeds } from './terminal-stream-byte-length' -import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control' // Copy of the pre-change implementation (shared/clipboard-text.ts // measureClipboardTextByteLength), kept here so equivalence is checked against the @@ -323,92 +322,6 @@ describe('terminal stream byte length equivalence with the legacy code-point sca } } } - expect(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS).toBeGreaterThan(0) - }) -}) - -// Reproduces createTerminalOutputBatcher's byte accounting exactly, under both the -// legacy scan and the new measurement, and asserts IDENTICAL flush boundaries. This is -// what makes the partial-count behaviour provably unobservable at that call site. -function simulateBatcherFlushes( - chunks: string[], - measure: ( - data: string, - options: { stopAfterBytes?: number } - ) => { byteLength: number; exceededLimit: boolean } -): string[] { - const flushes: string[] = [] - let pending: string[] = [] - let bytes = 0 - const flush = (): void => { - if (pending.length === 0) { - return - } - flushes.push(pending.join('')) - pending = [] - bytes = 0 - } - for (const data of chunks) { - if (!data) { - continue - } - pending.push(data) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measure(data, { stopAfterBytes: remainingBudget }) - bytes += measurement.byteLength - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - } - } - flush() - return flushes -} - -describe('terminal output batcher flush boundaries are unchanged', () => { - it( - 'produces identical flush boundaries over randomized multi-chunk runs', - { timeout: 60000 }, - () => { - const random = mulberry32(0x1337) - for (let run = 0; run < 300; run += 1) { - const chunks: string[] = [] - const chunkCount = 1 + Math.floor(random() * 40) - for (let index = 0; index < chunkCount; index += 1) { - // Sizes straddle the 64KiB batch cap so single chunks both fit and blow the budget. - // Sizes straddle the native-call floor too, so runs mix scan-branch and - // native-branch measurements inside one batcher's byte accounting. - const scale = random() - const maxUnits = - scale < 0.25 - ? MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2 - : scale < 0.5 - ? 64 - : scale < 0.85 - ? 20000 - : 90000 - chunks.push(random() < 0.5 ? randomString(random, maxUnits) : rawUtf16(random, maxUnits)) - } - const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) - const actualFlushes = simulateBatcherFlushes(chunks, measureTerminalStreamByteLength) - if (legacyFlushes.length !== actualFlushes.length) { - throw new Error(`flush count diverged on run ${run}`) - } - for (let index = 0; index < legacyFlushes.length; index += 1) { - if (legacyFlushes[index] !== actualFlushes[index]) { - throw new Error(`flush ${index} diverged on run ${run}`) - } - } - } - expect(true).toBe(true) - } - ) - - it('exercises runs that actually cross the batch budget', () => { - const oversized = '\u{1f600}'.repeat(TERMINAL_OUTPUT_BATCH_MAX_BYTES) - const chunks = ['a'.repeat(10), oversized, 'b'.repeat(10), oversized, 'c'] - const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) - expect(legacyFlushes.length).toBeGreaterThan(1) - expect(simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)).toEqual(legacyFlushes) }) }) @@ -419,23 +332,8 @@ describe('resync trim byte accounting for a snapshot-sliced chunk', () => { it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => { const data = '\u{1f600}é走a' const sliced = data.slice(2) - expect(terminalStreamByteLength(sliced)).toBe(legacyByteLength(sliced)) // Guards the mutant: code-unit length would be 4 here, UTF-8 is 6. expect(terminalStreamByteLength(sliced)).toBe(6) expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length) }) - - it('matches the legacy byte length for every suffix slice of multi-byte terminal text', () => { - const random = mulberry32(0x51ced) - for (let iteration = 0; iteration < 2000; iteration += 1) { - const data = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24) - for (let offset = 0; offset <= data.length; offset += 1) { - const sliced = data.slice(offset) - if (terminalStreamByteLength(sliced) !== legacyByteLength(sliced)) { - throw new Error(`sliced byte length diverged for ${JSON.stringify(data)} at ${offset}`) - } - } - } - expect(true).toBe(true) - }) }) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index f1ac250bdc3..45798a89528 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -88,13 +88,6 @@ describe('WebSocketTransport', () => { }) } - it('starts and stops cleanly', async () => { - const { transport } = await createTransport() - - await transport.start() - await transport.stop() - }) - it('arms heartbeat only while accepted connections exist', async () => { const { transport } = await createTransport() await transport.start() diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index c932e59a904..d29b9afea1e 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -22,6 +22,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { RuntimeStore } from './runtime-store-contract' export type RuntimeClientSettings = Pick< @@ -233,11 +234,7 @@ export class RuntimeClientSettingsController { onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() - return ( - current !== undefined && - current.agentStatusHooksEnabled !== false && - !current.disabledTuiAgents?.includes(agent) - ) + return current !== undefined && isAgentStatusHooksEnabledForAgent(current, agent) } }) }) diff --git a/src/main/runtime/runtime-folder-worktree-create.test.ts b/src/main/runtime/runtime-folder-worktree-create.test.ts index 885e61010dd..217d958d96e 100644 --- a/src/main/runtime/runtime-folder-worktree-create.test.ts +++ b/src/main/runtime/runtime-folder-worktree-create.test.ts @@ -28,7 +28,6 @@ function createDeps() { store: store as unknown as CreateArgs['deps']['store'], ptySpawnAvailable: true, createTerminal, - markTrusted: vi.fn(), pasteDraft: vi.fn(), sendFollowup: vi.fn(), invalidateResolvedWorktrees: vi.fn(), diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index fd13253c488..1b3cd2a829e 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -26,7 +26,6 @@ type RuntimeFolderWorktreeCreateDeps = { selector: string, options: TerminalCreateOptions ) => Promise - markTrusted: (agent: TuiAgent, path: string) => Promise pasteDraft: (handle: string, draft: WorktreeStartupDraftPaste) => void sendFollowup: (handle: string, followup: WorktreeStartupFollowup) => void invalidateResolvedWorktrees: () => void @@ -131,10 +130,6 @@ export async function createRuntimeFolderWorktree(args: { let startupTerminal: CreateWorktreeResult['startupTerminal'] if (args.startup && deps.ptySpawnAvailable) { try { - const trustAgent = args.draftPaste?.agent ?? args.createdWithAgent - if (trustAgent) { - await deps.markTrusted(trustAgent, worktree.path) - } const terminal = await deps.createTerminal(`id:${worktree.id}`, { command: args.startup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), diff --git a/src/main/runtime/runtime-local-create-rearm-ordering.test.ts b/src/main/runtime/runtime-local-create-rearm-ordering.test.ts index 27294c144c3..caf4dfb109f 100644 --- a/src/main/runtime/runtime-local-create-rearm-ordering.test.ts +++ b/src/main/runtime/runtime-local-create-rearm-ordering.test.ts @@ -33,7 +33,10 @@ vi.mock('./runtime-local-worktree-setup', () => ({ })) })) -vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() +})) import { OrcaRuntimeService } from './orca-runtime' diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index 1e590f22973..a80351c6af1 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -41,7 +41,6 @@ function createPorts() { }) const ports: StartupArgs['ports'] = { canSpawn: true, - markTrusted: vi.fn(), createTerminal, pasteDraft: vi.fn(), sendFollowup: vi.fn(), diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index b3cb5e71850..e878eaa4b16 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -20,7 +20,6 @@ import type { type Ports = { canSpawn: boolean - markTrusted: (agent: TuiAgent, path: string) => Promise createTerminal: ( selector: string, options: TerminalCreateOptions @@ -94,10 +93,6 @@ export async function startRuntimeLocalWorktreeTerminals(args: { if (sequencedStartup && ports.canSpawn) { try { - const trustAgent = args.draftPaste?.agent ?? args.createdWithAgent - if (trustAgent) { - await ports.markTrusted(trustAgent, worktree.path) - } const terminal = await ports.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts index 4dfa1ba2647..f642a07aeab 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts @@ -37,7 +37,6 @@ function createDeps() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create only forwards the store to the mocked request helper, which never reads it. store: {} as unknown as CreateParams[2]['store'], canSpawn: () => true, - markTrusted: vi.fn(), createTerminal, pasteDraft: vi.fn(), sendFollowup: vi.fn(), diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 85ff97f10a0..8d5b4a60a3d 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -19,11 +19,6 @@ import { finishRuntimeRemoteWorktreeCreate } from './runtime-remote-worktree-cre type Dependencies = { store: RuntimeStore canSpawn(): boolean - markTrusted( - agent: NonNullable, - connectionId: string, - path: string - ): Promise createTerminal( selector: string, options: TerminalCreateOptions @@ -105,10 +100,6 @@ export async function createRuntimeRemoteManagedWorktree( if (sequencedStartup && deps.canSpawn()) { try { - const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent - if (startupTrustAgent) { - await deps.markTrusted(startupTrustAgent, repo.connectionId!, result.worktree.path) - } const terminal = await deps.createTerminal(`path:${result.worktree.path}`, { command: sequencedStartup.command, ...(args.startupCwd ? { cwd: args.startupCwd } : {}), 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 a543a8da45d..d544dc6720b 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 @@ -218,6 +218,9 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', 'agentSession.close', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index ef245466b5a..0d9180dc3e5 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -13,7 +14,7 @@ import type { } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' @@ -67,7 +68,7 @@ async function openJournal(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) } @@ -170,7 +171,7 @@ describe('a request that failed reads as failed through the feed, the ingest and outcome: 'cancellation', completedAt: 5 }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { status, ps } = ingest(publishedSummary(journal)) @@ -186,7 +187,7 @@ describe('a request that failed reads as failed through the feed, the ingest and await journal.appendItem( TURN_IDENTITY, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // The row folds the host's live child records, never the summary's task list. const { status, ps } = ingest(publishedSummary(journal), [ diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index ba2c57b7854..5fa98186449 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -3,31 +3,18 @@ import type { Repo } from '../../shared/repo-types' import { tuiAgentToAgentKind } from '../../shared/agent-kind' const mocks = vi.hoisted(() => ({ - markAntigravityWorkspaceTrusted: vi.fn(), - markCodexProjectTrusted: vi.fn(), - markCopilotFolderTrusted: vi.fn(), - markCursorWorkspaceTrusted: vi.fn(), detectRemoteAgents: vi.fn(), detectInstalledAgentsWithShellPathHydration: vi.fn() })) -vi.mock('../agent-trust-presets', () => ({ - markAntigravityWorkspaceTrusted: mocks.markAntigravityWorkspaceTrusted, - markCodexProjectTrusted: mocks.markCodexProjectTrusted, - markCopilotFolderTrusted: mocks.markCopilotFolderTrusted, - markCursorWorkspaceTrusted: mocks.markCursorWorkspaceTrusted -})) - vi.mock('../preflight/agent-detection', () => ({ detectRemoteAgents: mocks.detectRemoteAgents, detectInstalledAgentsWithShellPathHydration: mocks.detectInstalledAgentsWithShellPathHydration })) -import { AGENT_TRUST_WRITE_DEADLINE_MS } from '../agent-trust-write-deadline' import { buildWorktreeStartupForAgent, - buildWorktreeStartupForDraft, - markLocalWorktreeTrusted + buildWorktreeStartupForDraft } from './runtime-worktree-agent-startup' function makeRepo(fields: Partial): Repo { @@ -181,92 +168,3 @@ describe('buildWorktreeStartupForDraft agent detection', () => { } ) }) - -describe('markLocalWorktreeTrusted', () => { - it('waits for the Codex trust write before resolving', async () => { - let finish!: () => void - mocks.markCodexProjectTrusted.mockReturnValue( - new Promise((resolve) => { - finish = resolve - }) - ) - let settled = false - const marking = markLocalWorktreeTrusted('codex', '/workspace/app').then(() => { - settled = true - }) - - await Promise.resolve() - expect(settled).toBe(false) - finish() - await marking - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledWith('/workspace/app') - }) - - it('contains a rejected Codex trust write', async () => { - mocks.markCodexProjectTrusted.mockRejectedValueOnce(new Error('write failed')) - - await expect(markLocalWorktreeTrusted('codex', '/workspace/app')).resolves.toBeUndefined() - }) - - /** - * Why this test exists: Orca has two trust dispatch chains — the renderer's - * preflightAgentTrust (via the agentTrust:markTrusted IPC) and this main-process - * one, which is the only path `orchestration worker-start` takes. Adding - * `preflightTrust: 'antigravity'` to TUI_AGENT_CONFIG clears the `!preset` guard - * here but matched none of the cursor/copilot/codex branches, so every supervised - * agy worker still failed at agent_readiness with 'agent-trust-workspace' while - * the renderer-side unit tests passed. Verified live: with the branch added, the - * worktree is appended to ~/.gemini/antigravity-cli/settings.json and the dispatch - * reaches worker_done. - */ - it('writes the agy workspace trust artifact on the orchestration path', async () => { - await markLocalWorktreeTrusted('antigravity', '/workspace/app') - - expect(mocks.markAntigravityWorkspaceTrusted).toHaveBeenCalledWith('/workspace/app') - }) - - it('contains a throwing agy trust write', async () => { - mocks.markAntigravityWorkspaceTrusted.mockImplementationOnce(() => { - throw new Error('write failed') - }) - - await expect(markLocalWorktreeTrusted('antigravity', '/workspace/app')).resolves.toBeUndefined() - }) - - it('bounds a never-settling Codex trust write instead of holding the launch open', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let release!: () => void - const write = new Promise((resolve) => { - release = resolve - }) - mocks.markCodexProjectTrusted.mockClear() - mocks.markCursorWorkspaceTrusted.mockClear() - mocks.markCopilotFolderTrusted.mockClear() - mocks.markAntigravityWorkspaceTrusted.mockClear() - mocks.markCodexProjectTrusted.mockReturnValueOnce(write) - let settled = false - const marking = markLocalWorktreeTrusted('codex', '/workspace/app').then(() => { - settled = true - }) - try { - await vi.advanceTimersByTimeAsync(AGENT_TRUST_WRITE_DEADLINE_MS - 1) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(2) - await marking - expect(settled).toBe(true) - expect(String(warn.mock.calls[0]?.[0])).toContain('did not settle') - expect(vi.getTimerCount()).toBe(0) - // Fails closed: giving up writes nothing, retries nothing and never - // substitutes another preset's artifact, so Codex still prompts. - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledTimes(1) - expect(mocks.markCursorWorkspaceTrusted).not.toHaveBeenCalled() - expect(mocks.markCopilotFolderTrusted).not.toHaveBeenCalled() - expect(mocks.markAntigravityWorkspaceTrusted).not.toHaveBeenCalled() - } finally { - warn.mockRestore() - release() - vi.useRealTimers() - } - }) -}) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index faca0baf8fa..a65f2527357 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -1,4 +1,3 @@ -import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' import type { Repo } from '../../shared/repo-types' @@ -6,22 +5,14 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import { repoIsRemote } from '../../shared/agent-launch-remote' import { getRepoSshConnectionId } from '../../shared/execution-host' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { isTuiAgent } from '../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' -import { - markAntigravityWorkspaceTrusted, - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' -import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../preflight/agent-detection' -import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' import type { RuntimeStore } from './runtime-store-contract' export type WorktreeStartupDraftPaste = { agent: TuiAgent; content: string } @@ -182,48 +173,3 @@ export function buildWorktreeStartupForAgent( : {}) } } - -export async function markLocalWorktreeTrusted( - agent: TuiAgent, - workspacePath: string -): Promise { - const preset = TUI_AGENT_CONFIG[agent].preflightTrust - if (!preset) { - return - } - try { - if (preset === 'qoder') { - markQoderWorkspaceTrusted(workspacePath) - } else if (preset === 'cursor') { - markCursorWorkspaceTrusted(workspacePath) - } else if (preset === 'copilot') { - markCopilotFolderTrusted(workspacePath) - } else if (preset === 'codex') { - // Why: the Codex write queues behind any in-flight hook grant, so the agent must not launch until it lands. Bounded so a wedged lane degrades to the agent's own prompt instead of stalling the launch. - await awaitAgentTrustWriteWithinDeadline(markCodexProjectTrusted(workspacePath), { - preset, - workspacePath - }) - } else if (preset === 'antigravity') { - markAntigravityWorkspaceTrusted(workspacePath) - } - } catch { - // Best-effort: the user can still accept the agent trust prompt manually. - } -} - -export async function markRemoteWorktreeTrusted( - agent: TuiAgent, - connectionId: string, - workspacePath: string -): Promise { - const preset = TUI_AGENT_CONFIG[agent].preflightTrust - if (!preset) { - return - } - try { - await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) - } catch { - // Best-effort: the user can still accept the remote agent trust prompt manually. - } -} diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index a1373f43c71..50e974f1eb5 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { mkdtemp, rm } from 'node:fs/promises' @@ -5,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' @@ -66,12 +67,12 @@ async function awaitingApproval() { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { ...IDENTITY, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { ...IDENTITY, ordinal: 2 }, @@ -82,7 +83,7 @@ async function awaitingApproval() { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ diff --git a/src/main/runtime/startup-dialog-blocked-signals.ts b/src/main/runtime/startup-dialog-blocked-signals.ts new file mode 100644 index 00000000000..e964205a9cc --- /dev/null +++ b/src/main/runtime/startup-dialog-blocked-signals.ts @@ -0,0 +1,76 @@ +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' + +// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the +// startup header for the live chat's footer, so each dialog must be matched by the time that `·` +// lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the +// text copy can lose letters and spaces (`updat available`); these rows are fixed literals. +// Update: `Update available · 0.157.1 → 0.158.0`, then `enter continue · esc skip`. +const CODEX_UPDATE_ROW_RE = /available\s*·\s*\d+\.\d+|enter\s*continue\s*·\s*esc\s*skip/g +// Why not `esc back`: Codex's mid-session pickers end `enter confirm · esc back`. +const CODEX_HOOKS_REVIEW_KEY_ROW_RE = /enter\s*confirm\s*·(?!\s*esc\s*back)/g +// Why both verbs: the retired-model notice says `continue`, the new-model announcement `confirm`. +const CODEX_MODEL_MIGRATION_KEY_ROW_RE = /enter\/esc\s*(?:continue|confirm)\s*·/g + +function lastMatchIndex(text: string, row: RegExp): number { + let index = -1 + for (const match of text.matchAll(row)) { + index = match.index + } + return index +} + +// Why the last match, and the legacy wording too: an answered dialog stays in the text copy, and +// builds before 0.157 wrote `Press enter to continue/confirm`. +function findDialogIndex( + normalized: string, + legacyHeading: string, + legacyKeys: string, + row: RegExp +): number { + const rowIndex = lastMatchIndex(normalized, row) + const headingIndex = normalized.lastIndexOf(legacyHeading) + const legacyIndex = + headingIndex !== -1 && normalized.includes(legacyKeys, headingIndex) ? headingIndex : -1 + return Math.max(rowIndex, legacyIndex) +} + +// Why together: each startup dialog owns Enter before the chat exists, so a brief typed into one +// answers it (Codex's update dialog defaults to `Update now`). +export function findStartupDialogBlockedSignals( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number }[] { + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = findDialogIndex( + normalized, + 'update available', + 'press enter to continue', + CODEX_UPDATE_ROW_RE + ) + if (updateIndex !== -1) { + candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = findDialogIndex( + normalized, + 'codex just got an upgrade', + 'press enter to continue', + CODEX_MODEL_MIGRATION_KEY_ROW_RE + ) + if (modelMigrationIndex !== -1) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = findDialogIndex( + normalized, + 'hooks need review', + 'press enter to confirm', + CODEX_HOOKS_REVIEW_KEY_ROW_RE + ) + if (hooksIndex !== -1) { + // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. + candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) + } + return candidates +} diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 6190ad3bd00..060fbea5b1f 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -38,23 +38,15 @@ export type StructuredCodexAccountHomeDeps = { * null → system-home mapping below, so the two paths cannot drift. */ resolveLaunchHome: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) + | ((input: { launchEnv: NodeJS.ProcessEnv }) => string | null | Promise) | null - /** Empty for a record-less read; only launch preparation consumes it. */ - workspacePath: string } export async function resolveStructuredCodexAccountHomePath( deps: StructuredCodexAccountHomeDeps ): Promise { // A create has no process yet, so the current selection is what it must follow. - const resolvedHome = await deps.resolveLaunchHome?.({ - workspacePath: deps.workspacePath, - launchEnv: deps.launchEnv - }) + const resolvedHome = await deps.resolveLaunchHome?.({ launchEnv: deps.launchEnv }) const configuredHome = deps.launchEnv.CODEX_HOME return ( resolvedHome?.trim() || diff --git a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts new file mode 100644 index 00000000000..ee3876301df --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts @@ -0,0 +1,154 @@ +// A Codex child that exits, or that Stop ends, while `/compact` runs, observed the way the shipping +// adapter observes it: the app-server connection's own exit, not a hand-fed compaction result. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The desktop's own client: a send answers at acceptance, so one queued behind the command + // returns while the command still runs. + harness = await openStructuredCodexRpcHarness(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES) +}) + +afterEach(async () => { + await harness.dispose() +}) + +async function snapshot() { + return (await ensureStructuredAgentSessionHost(harness.hostConfig())).journalSnapshot(SESSION) +} + +/** The connections that received `method`, once per call. */ +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +/** The child serving the thread; a model-list probe opens connections of its own. */ +function threadChild(): FakeCodexConnection { + const child = harness.codex.connections.findLast((connection) => + connection.calls.some( + (entry) => entry.method === 'thread/start' || entry.method === 'thread/resume' + ) + ) + if (!child) { + throw new Error('no codex child opened the thread') + } + return child +} + +async function startCompact(fence: number): Promise { + const params = { + command: 'compact', + envelope: harness.envelope('agentSession.conversationCommand', { command: 'compact' }, fence) + } + await harness.ok('agentSession.conversationCommand', params) + return params.envelope.clientOperationId +} + +async function send(text: string): Promise { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function exitChild(): FakeCodexConnection { + const child = threadChild() + child.handlers.onExit?.(new Error('codex app-server exited')) + return child +} + +it('delivers what waited behind the command once its child dies mid-command, with one exit row', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + await send('after the exit') + + const dead = exitChild() + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(dead) + const settled = await snapshot() + const turn = settled.items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + expect( + settled.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) +}) + +it('runs a later command after Stop named the one whose child died', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const dead = exitChild() + await vi.waitFor(async () => { + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + }) + const { turnId } = structuredAgentSessionCommandTurn(command) + await harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + + // Nothing the dead command left behind holds the queue or refuses the next one. + const later = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(2)) + expect(calls('thread/compact/start')[1]).not.toBe(dead) + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(later).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('running') +}) + +it('ends a command Codex has not opened a turn for by stopping its child, and the next send gets a fresh one', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + const first = threadChild() + const { itemId, turnId } = structuredAgentSessionCommandTurn(command) + + // No `turn/started` yet, so there is no provider turn to interrupt. + await expect( + harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + ).resolves.toMatchObject({ cancelled: true }) + + expect(first.closed).toBe(true) + expect(first.calls.some((entry) => entry.method === 'turn/interrupt')).toBe(false) + const turn = (await snapshot()).items.find((item) => item.itemId === itemId) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + await send('after the stop') + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(first) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index fc06a2a782b..990c33850ef 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,5 +1,6 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn -// withdraws it, and nothing reads as working after. Driven through the shipped host, +// withdraws it, and nothing reads as working after. A Stop sent before Codex opens that +// turn waits for it to open, since Codex refuses an interrupt until then. Driven through the shipped host, // journal and Codex adapter; only the Codex child is fake, keeping Codex 0.157's // turn bookkeeping. @@ -13,6 +14,8 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import { CODEX_STOP_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-prompt-ownership' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' @@ -24,17 +27,12 @@ import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { CHILD_EVICTION_TIMEOUT_MS } from '../native-chat/agent-session-wire/structured-agent-session-host-teardown' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -// A Stop must never reach for real processes on this machine under a made-up pid. -vi.mock('../codex/codex-structured-turn-processes', () => ({ - captureCodexTurnProcesses: async () => null, - terminateCodexTurnProcesses: async () => true -})) - const CALLER = { callerKey: 'codex-turn-end-test' } const MODEL = { model: 'gpt-test', @@ -50,6 +48,8 @@ let host: StructuredAgentSessionHost let fence: number let handlers: CodexAppServerConnectionHandlers | undefined let answers: number +let interrupts: number +let childCloses: number let turns: ReturnType let operations = 0 @@ -78,10 +78,10 @@ async function send(text: string): Promise { return sent.value.clientMessageId } -async function stop(turnId: string): Promise { +async function stop(turnId?: string): Promise { const stopped = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId }), - turnId + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) }) if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) @@ -111,6 +111,8 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) answers = 0 + interrupts = 0 + childCloses = 0 turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -135,6 +137,7 @@ beforeEach(async () => { return turns.routes['turn/start']() } if (method === 'turn/interrupt') { + interrupts += 1 return turns.routes['turn/interrupt'](params) } return {} @@ -142,7 +145,10 @@ beforeEach(async () => { notify: () => {}, respond: () => {}, respondWithError: () => {}, - close: async () => true + close: async () => { + childCloses += 1 + return true + } } return connection } @@ -215,3 +221,98 @@ describe('a Codex send its turn ended without taking it', () => { expect(after.owesWork).toBe(false) }) }) + +describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { + it('waits for the turn to open, then stops it and withdraws the send', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + + const stopping = stop() + // Without the wait, it would reach Codex now, which would refuse it, and be done. + expect(await settledWithin(stopping, 1_000)).toBe('held') + expect(interrupts).toBe(0) + turns.start() + await stopping + + expect(interrupts).toBe(1) + expect(turns.turnId).toBeNull() + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + expect((await settled()).owesWork).toBe(false) + }) +}) + +describe('a Stop in that window that the turn never opens for', () => { + async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) + } + + async function waitingStop(): Promise<{ stopping: Promise }> { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + expect(await settledWithin(stopping, 200)).toBe('held') + return { stopping } + } + + it('says Codex had no turn running when that turn ends first', async () => { + const { stopping } = await waitingStop() + + turns.end('interrupted') + await stopping + + expect(interrupts).toBe(0) + expect(await statusRows()).toContain('Codex had no turn running to stop.') + }) + + it('lets a chat closed behind it close within its bound and one eviction', async () => { + const { stopping } = await waitingStop() + + const closing = host.close(SESSION) + + expect( + await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) + ).not.toBe('held') + expect(await settledWithin(stopping, 0)).not.toBe('held') + expect(childCloses).toBe(1) + expect(interrupts).toBe(0) + expect(await statusRows()).toContain('Codex had no turn running to stop.') + }) + + it('lets the app quit behind it within the eviction budget, and still close the child', async () => { + await waitingStop() + + expect( + await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) + ).not.toBe('held') + expect(childCloses).toBe(1) + }) +}) + +describe('a cold send with no Stop behind it', () => { + /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ + const PROMPTLY_MS = 1_000 + + async function answeredColdSend(): Promise { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + expect(turns.turnId).toBe('turn-1') + } + + it('never delays closing the chat', async () => { + await answeredColdSend() + + expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held') + expect(childCloses).toBe(1) + }) + + it('never delays quitting the app', async () => { + await answeredColdSend() + + expect(await settledWithin(stopStructuredAgentSessionRuntime(), PROMPTLY_MS)).not.toBe('held') + expect(childCloses).toBe(1) + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts new file mode 100644 index 00000000000..689ac3df969 --- /dev/null +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -0,0 +1,397 @@ +// A process whose chat journal will not open refuses every chat, leaves the file alone, and +// otherwise starts as usual: terminals, tabs and session history go on without a host. + +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import Database from '../sqlite/sync-database' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' +import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' +import type { RpcContext } from './rpc/core' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string + +// An in-process caller: the same build as the host, so the gate asks it for no capability. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. +const IN_PROCESS = {} as RpcContext + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +/** The refusal as the gate throws it for every structured request. */ +function gateRefusal(): { reason: unknown; message: string } { + try { + requireStructuredCleanupHost(IN_PROCESS) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { reason: error.refusal.details?.reason, message: error.refusal.message } + } + throw error + } + throw new Error('the gate admitted the request') +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-host-refusal-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('a process whose journal will not open', () => { + // T-corrupt-open: the error surfaces, every chat says it cannot be loaded, and nothing is + // renamed, deleted or rebuilt. + it('refuses every chat and leaves a damaged file exactly as it is', async () => { + const path = journalDatabasePath(root) + await writeFile(path, 'not a database '.repeat(512)) + const before = await digest(path) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(await digest(path)).toBe(before) + expect(existsSync(`${path}-wal`)).toBe(false) + expect(existsSync(`${path}-shm`)).toBe(false) + + // Once a person moves the file aside, the next request installs. + await unlink(path) + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + }) + + // A database from an unreleased build of this change: never migrated or renamed, so it reads as + // damage, and the log says where it is and what to do. + it('refuses a database an unreleased build wrote as unusable, and says to move it aside', async () => { + const path = journalDatabasePath(root) + const earlier = new Database(path) + earlier.exec('CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, row_json TEXT NOT NULL)') + earlier.pragma('user_version = 2') + earlier.close() + const before = await digest(path) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + } + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(warn).toHaveBeenCalledOnce() + const logged = String(warn.mock.calls[0]?.[1]) + expect(logged).toContain(path) + expect(logged).toContain('unreleased development build') + expect(logged).toContain('move the file aside') + expect(await digest(path)).toBe(before) + }) + + // T5: a newer build's database is not refused: the host installs over it read-only, so chats + // stay visible, and the file is left byte-identical. + it('installs over a database a newer Orca wrote, and leaves it byte-identical', async () => { + const path = journalDatabasePath(root) + const seeded = new Database(path) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + seeded.close() + const before = await digest(path) + + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + await stopStructuredAgentSessionRuntime() + expect(await digest(path)).toBe(before) + }) +}) + +describe('logging a journal that will not open', () => { + const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' + + async function writeJunkJournal(): Promise { + const path = journalDatabasePath(root) + await rm(`${path}-wal`, { force: true }) + await rm(`${path}-shm`, { force: true }) + await writeFile(path, 'not a database '.repeat(512)) + } + + // Every chat request retries the open; the same failure each time is one log, not one per request. + it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + expect(openFailureLogs()).toHaveLength(1) + expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) + expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) + + await unlink(journalDatabasePath(root)) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + expect(openFailureLogs()).toHaveLength(2) + }) +}) + +// A refused host is a no-host state for startup: the app restores terminals and tabs as usual, +// and only structured requests are refused. +describe('startup and other non-chat work without a structured host', () => { + function startupRuntime(ensureHost: () => Promise = install) { + const runtime = new OrcaRuntimeService() + const refreshPtyRecords = vi.fn(async () => new Set()) + const hydrateTabs = vi.fn(() => new Set()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = ensureHost + internal.refreshMobileSessionPtyRecords = refreshPtyRecords + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrateTabs + return { runtime, refreshPtyRecords, hydrateTabs } + } + + async function expectStartupWithoutHost(runtime: OrcaRuntimeService): Promise { + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + // What `session.tabs.list` awaits before it answers a paired client. + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + expect(getStructuredAgentSessionHost()).toBeNull() + } + + async function writeJunkJournal(): Promise { + await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) + } + + it('goes ahead when its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() + + await expectStartupWithoutHost(runtime) + + expect(refreshPtyRecords).toHaveBeenCalledOnce() + expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + }) + + // The inventory must say "cannot tell", never "no chats": a client culls what an answer omits, + // and the desktop then saves its chat tabs away. + describe('the session-tabs inventory', () => { + /** The worktree's frame as the renderer's own graph publication leaves it: no chat rows. */ + const WORKTREE_FRAME: RuntimeMobileSessionTabsResult = { + worktree: 'workspace-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + + function publishWorktreeFrame(runtime: OrcaRuntimeService): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected store, called as its graph publication does. + const internal = runtime as unknown as { + storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown + } + internal.storeMobileSessionSnapshot('workspace-1', WORKTREE_FRAME) + } + + async function listInventory(runtime: OrcaRuntimeService) { + await runtime.restoreStructuredAgentSessionTabs() + return runtime.listAllMobileSessionTabs() + } + + it('marks chats unverifiable when its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toEqual([ + expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + ]) + }) + + // A tap's reply goes straight back to the tapping client, not through the list. + it.each(['caller', 'clients'])( + 'marks a %s navigation reply unverifiable while its journal will not open', + async (navigation) => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + await listInventory(runtime) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected navigation step, called as `session.tabs.activate` does. + const internal = runtime as unknown as { + applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult + } + + const reply = internal.applyMobileSessionTabNavigation( + WORKTREE_FRAME, + 'agent-session:claude-1', + navigation, + 'phone-1' + ) + + expect(reply).toMatchObject({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + } + ) + + it('lists no chats as a real answer once there is a host', async () => { + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toHaveLength(1) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(getStructuredAgentSessionHost()).not.toBeNull() + }) + + // Subscribers still hold the frames that said "cannot tell"; the restore that can tell pushes. + it('pushes the chats without the mark once a later list has a host', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) + const pushed: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) + + // Once a person moves the file aside, a chat request installs the host. + await unlink(journalDatabasePath(root)) + const host = await install() + vi.spyOn(host, 'listSessionTabs').mockReturnValue([ + { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } + ]) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + const frames = await listInventory(runtime) + unsubscribe() + + const chats = [expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1' })] + expect(frames).toEqual([expect.objectContaining({ tabs: chats })]) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(pushed.at(-1)?.tabs).toEqual(chats) + expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') + }) + }) + + it('lets a terminal resume command through while its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // The check `terminal.send` and `session.tabs.createTerminal` run before typing a resume. + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + await install() + }) + ).resolves.toBeUndefined() + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + throw new Error('the record store would not open') + }) + ).rejects.toThrow('the record store would not open') + expect(gateRefusal().reason).toBe('journalCorrupt') + }) + + it('still fails on an install error that refuses nothing', async () => { + const { runtime, refreshPtyRecords } = startupRuntime(async () => { + throw new Error('the record store would not open') + }) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( + 'the record store would not open' + ) + expect(refreshPtyRecords).not.toHaveBeenCalled() + }) +}) + +describe('stopping', () => { + it('keeps the connection until a journal close that failed is retried and goes through', async () => { + await install() + const close = JournalHostDatabase.prototype.close + let connectionClose: ReturnType | null = null + const journalClose = vi + .spyOn(JournalHostDatabase.prototype, 'close') + .mockImplementation(function (this: JournalHostDatabase) { + // The connection's first close fails, as a native close can before it completes. + connectionClose ??= vi.spyOn(this.db, 'close').mockImplementationOnce(() => { + throw new Error('unable to close due to unfinalized statements') + }) + close.call(this) + }) + const journal = () => journalClose.mock.contexts[0] + + await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow('unable to close') + expect(journal()).toHaveProperty('isClosed', false) + + await stopStructuredAgentSessionRuntime() + expect(connectionClose).toHaveBeenCalledTimes(2) + expect(journal()).toHaveProperty('isClosed', true) + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.ts b/src/main/runtime/structured-agent-session-host-refusal.ts new file mode 100644 index 00000000000..30465c0fb12 --- /dev/null +++ b/src/main/runtime/structured-agent-session-host-refusal.ts @@ -0,0 +1,47 @@ +// Why structured chats are refused in this process: its chat journal would not open at install +// (damaged or unavailable). Everything else goes on without a host. + +import { + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../shared/agent-session-wire-refusals' + +let installRefusal: AgentSessionRefusalError | null = null + +/** Why structured chats are refused in this process, or null when nothing refuses them. */ +export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | null { + return installRefusal +} + +/** Whether `error` is the refusal structured requests are getting right now. */ +function isStructuredAgentSessionHostRefusal(error: unknown): boolean { + const refusal = installRefusal + return ( + refusal !== null && + isAgentSessionRefusalError(error) && + error.refusal.code === refusal.refusal.code && + error.refusal.message === refusal.refusal.message + ) +} + +/** For work that goes on without chats: the refusal chats are getting leaves this process with no + * host, and any other install failure still throws. */ +export async function ensureStructuredAgentSessionHostUnlessRefused( + ensureHost: () => Promise +): Promise { + try { + await ensureHost() + } catch (error) { + if (!isStructuredAgentSessionHostRefusal(error)) { + throw error + } + } +} + +/** Set when the journal database would not open; cleared by a later install. The install retries + * on the next call, so a refusal that can clear does. */ +export function recordStructuredAgentSessionHostInstallRefusal( + refusal: AgentSessionRefusalError | null +): void { + installRefusal = refusal +} diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts new file mode 100644 index 00000000000..53720659ec4 --- /dev/null +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -0,0 +1,68 @@ +// An install that fails after it opened the chat journal closes that connection, so the next +// install does not leave a second one open in the same process. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type * as ModelCatalogWiring from './structured-agent-model-catalog-wiring' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const mocks = vi.hoisted(() => ({ failWiring: vi.fn(() => false) })) + +// The last step before the host is built: a throw here comes after both stores opened. +vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + modelCatalogHostDeps: async (input: Parameters[0]) => { + if (mocks.failWiring()) { + throw new Error('model catalog wiring failed') + } + return actual.modelCatalogHostDeps(input) + } + } +}) + +let root: string + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-install-failure-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('an install that fails after opening the chat journal', () => { + it('closes the journal connection it opened, and the next install opens its own', async () => { + const open = vi.spyOn(JournalHostDatabase, 'open') + mocks.failWiring.mockReturnValueOnce(true) + + await expect(install()).rejects.toThrow('model catalog wiring failed') + const failed = open.mock.results[0]?.value + expect(failed).toBeInstanceOf(JournalHostDatabase) + expect(failed.isClosed).toBe(true) + + await expect(install()).resolves.toBeDefined() + expect(open).toHaveBeenCalledTimes(2) + expect(open.mock.results[1]?.value.isClosed).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index a4e2500ffc4..b4d0399d10d 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -6,234 +6,26 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection -} from '../codex/codex-app-server-connection' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import type { OrcaRuntimeService } from './orca-runtime' -import type { RpcRequest, RpcResponse } from './rpc/core' -import { RpcDispatcher } from './rpc/dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + TURN, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' const journals = createTrackedJournalOpener() - -const SESSION = 'session-integration-1' -const THREAD = 'thread-integration' -const TURN = 'turn-1' const WORKSPACE = 'workspace-1' -// The capability set the desktop renderer advertises. Without the pending-send -// one the host holds the reply until the send settles, which is a shim for -// clients too old to render a pending bubble — not what this suite models. -const CLIENT = { - clientId: 'device-a', - clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] -} -// ─── the fake `codex app-server` ──────────────────────────────────────────── - -type CodexScript = { - connections: FakeConnection[] - openConnection: typeof openCodexAppServerConnection - live: () => FakeConnection - notify: (method: string, params: unknown) => void - ask: (id: number, method: string, params: unknown) => void -} - -// `closed` is readonly on the real connection; the fake flips it so the test can -// see the takeover reap the previous child. -type FakeConnection = Omit & { - closed: boolean - handlers: CodexAppServerConnectionHandlers - calls: { method: string; params?: Record }[] - replies: { id: number | string; result?: unknown; code?: number }[] - resumedThreadId: string | null - launch: Parameters[0] -} - -function fakeCodex(): CodexScript { - const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { - const connection: FakeConnection = { - launch, - handlers, - calls: [], - replies: [], - resumedThreadId: null, - pid: 4321, - closed: false, - request: async (method, params) => { - connection.calls.push({ method, params }) - if (method === 'thread/start') { - return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } - } - if (method === 'thread/resume') { - connection.resumedThreadId = (params as { threadId: string }).threadId - return { thread: { id: connection.resumedThreadId } } - } - if (method === 'turn/start') { - return { turn: { id: TURN } } - } - if (method === 'model/list') { - return { - data: [ - { - model: 'gpt-live', - displayName: 'GPT Live', - hidden: false, - supportedReasoningEfforts: [ - { reasoningEffort: 'medium', description: 'Balanced' }, - { reasoningEffort: 'high', description: 'Deep reasoning' } - ], - defaultReasoningEffort: 'medium', - isDefault: true - } - ], - nextCursor: null - } - } - return {} - }, - notify: () => {}, - respond: (id, result) => connection.replies.push({ id, result }), - respondWithError: (id, code) => connection.replies.push({ id, code }), - close: async () => { - connection.closed = true - return true - } - } - connections.push(connection) - return connection - }) as typeof openCodexAppServerConnection - const live = (): FakeConnection => { - const connection = connections.at(-1) - if (!connection) { - throw new Error('no codex app-server has been opened') - } - return connection - } - return { - connections, - openConnection, - live, - notify: (method, params) => live().handlers.onNotification?.(method, params), - ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) - } -} - -// ─── the RPC client ───────────────────────────────────────────────────────── - -let operations = 0 - -/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real - * time, not a frozen constant: the runtime under test stamps the ledger with - * its own clock and refuses a future-dated id. */ -function operationId(): string { - operations += 1 - return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` -} - -function envelope(method: string, fields: Record, fence: number | null) { - return { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function attachParams(fence: number | null) { - const params = { - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE, - workspaceKind: 'git-worktree' as const - }, - provider: 'codex' as const, - agent: 'codex', - accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } - } - const envelope = { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '' - } - return { - ...params, - envelope: { - ...envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields({ ...params, envelope } as never) - }) - } - } -} - -function createIntentParams() { - const worktree = `id:${WORKSPACE}` - const fields = { worktree, agent: 'codex' } - return { envelope: envelope('agentSession.create', fields, null), ...fields } -} - -let codex: CodexScript -let root: string -let dispatcher: RpcDispatcher -let bootEnvironmentReads: number -let codexOverrideReads: number -let configuredCodexProfile: string - -/** Runs a one-shot method and returns its decoded reply. */ -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } - await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) - const first = replies[0] - if (!first) { - throw new Error(`no reply for ${method}`) - } - return first -} - -/** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ -async function ok(method: string, params: unknown): Promise { - const response = await call(method, params) - expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) - const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result - expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) - return result.value as T -} +let harness: StructuredCodexRpcHarness function textOf(item: AgentJournalRenderItem): string { const body = item.body @@ -243,69 +35,20 @@ function textOf(item: AgentJournalRenderItem): string { } beforeEach(async () => { - operations = 0 - root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) - codex = fakeCodex() - bootEnvironmentReads = 0 - codexOverrideReads = 0 - configuredCodexProfile = 'configured' - const runtime = { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {}, - ensureStructuredAgentSessionHost: () => - ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => { - bootEnvironmentReads += 1 - return { - PATH: '/shell/bin:/usr/bin', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', - CODEX_HOME: '/shell/home' - } - }, - resolveCodexOverrides: () => { - codexOverrideReads += 1 - return { CODEX_PROFILE: configuredCodexProfile } - }, - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }).then(() => undefined), - registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { - return { - releaseIfCurrent: dispose - } - }) - } - dispatcher = new RpcDispatcher({ - runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS - }) + harness = await openStructuredCodexRpcHarness() }) afterEach(async () => { await journals.closeAll() - await stopStructuredAgentSessionRuntime() - await rm(root, { recursive: true, force: true }) + await harness.dispose() }) describe('a structured codex session over agentSession.*', () => { it('replays a durable image send without dispatching it twice', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) const path = '/tmp/orca-paste-image.png' const body = { kind: 'message' as const, @@ -313,31 +56,23 @@ describe('a structured codex session over agentSession.*', () => { blocks: [{ type: 'image-ref' as const, path }] } const params = { - envelope: envelope('agentSession.send', { body }, created.fence), + envelope: harness.envelope('agentSession.send', { body }, created.fence), body } - const turnStarts = () => codex.live().calls.filter((entry) => entry.method === 'turn/start') - await ok('agentSession.send', params) + const turnStarts = () => + harness.codex.live().calls.filter((entry) => entry.method === 'turn/start') + await harness.ok('agentSession.send', params) // Accepted first; the delivery loop hands it over once. await vi.waitFor(() => expect(turnStarts()).toHaveLength(1)) - const replay = await call('agentSession.send', params) + const replay = await harness.call('agentSession.send', params) expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) expect(turnStarts()).toHaveLength(1) }) it('joins an acquired attach through journal bind before draining final rows', async () => { - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }) + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter const historyEntered = Promise.withResolvers() @@ -349,15 +84,18 @@ describe('a structured codex session over agentSession.*', () => { return originalHistoryFilePath(input) }) - const creating = ok<{ fence: number }>('agentSession.create', createIntentParams()) + const creating = harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) await historyEntered.promise - codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) - codex.notify('item/started', { + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + harness.codex.notify('item/started', { threadId: THREAD, turnId: TURN, item: { type: 'agentMessage', id: 'item-bind-window', text: '' } }) - codex.notify('item/agentMessage/delta', { + harness.codex.notify('item/agentMessage/delta', { threadId: THREAD, turnId: TURN, itemId: 'item-bind-window', @@ -384,7 +122,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: harness.root }) expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') expect( diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 1b7136a996d..efc2f786c99 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -6,7 +6,7 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -30,11 +30,11 @@ import type { } from '../../shared/agent-session-wire' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { OrcaRuntimeService } from './orca-runtime' +import { readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' @@ -410,7 +410,7 @@ describe('a structured codex session over agentSession.*', () => { } const journal = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') await writeFile( @@ -428,6 +428,8 @@ describe('a structured codex session over agentSession.*', () => { fence: 0, options: { filePath: rollout } }) + // The previous process exits, closing its database. + await journals.closeAll() const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( 'agentSession.create', @@ -454,7 +456,7 @@ describe('a structured codex session over agentSession.*', () => { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/home/dev/.codex' }) - const store = await readFile(join(root, 'agent-sessions', 'agent-sessions.json'), 'utf-8') + const store = await readPersistedTestAgentSessionStoreText(root) expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') expect(store).not.toContain('"launchEnv"') const stream = await subscribe('sub-first-send') @@ -827,7 +829,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') expect( diff --git a/src/main/runtime/structured-agent-session-inventory-hold.ts b/src/main/runtime/structured-agent-session-inventory-hold.ts new file mode 100644 index 00000000000..9ab1e59aeb1 --- /dev/null +++ b/src/main/runtime/structured-agent-session-inventory-hold.ts @@ -0,0 +1,20 @@ +/** + * Answers whether a snapshot bound for a client may be read as this runtime's chat inventory. + * + * Sets *and* clears, like the client-hosted page hold beside it, because a frame is built once + * and may already carry an answer from before the last tab restore. + */ +export function holdAgentSessionInventory( + result: T, + unverifiable: boolean +): T { + if (unverifiable === (result.agentSessionsUnverifiable === true)) { + return result + } + if (unverifiable) { + return { ...result, agentSessionsUnverifiable: true as const } + } + const { agentSessionsUnverifiable: _held, ...released } = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the optional hold field was removed, so the rest is still a T. + return released as T +} diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts new file mode 100644 index 00000000000..6972a18a796 --- /dev/null +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -0,0 +1,39 @@ +// Opening the chat journal database for the host install. + +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' + +// Every chat request retries a failed open, so each distinct failure is logged once, with its stack. +let lastLoggedOpenFailure: string | null = null + +function logOpenFailureOnce(error: unknown): void { + const failure = + error instanceof Error + ? `${'code' in error ? String(error.code) : ''}:${error.message}` + : String(error) + if (failure === lastLoggedOpenFailure) { + return + } + lastLoggedOpenFailure = failure + console.warn('[structured-agent-session] opening the chat journal database failed', error) +} + +/** The journal database. A refusal is recorded for the gate and thrown to the caller; the next + * install tries again. */ +export function openStructuredAgentSessionJournalDatabase( + stateDirectory: string +): JournalHostDatabase { + try { + const opened = JournalHostDatabase.open(stateDirectory) + recordStructuredAgentSessionHostInstallRefusal(null) + lastLoggedOpenFailure = null + return opened + } catch (error) { + logOpenFailureOnce(error) + // Nothing is renamed, deleted or rebuilt: the file is left exactly as it is. + const refusal = journalOpenRefusalError(error) + recordStructuredAgentSessionHostInstallRefusal(refusal) + throw refusal + } +} diff --git a/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts new file mode 100644 index 00000000000..fb820891ba5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts @@ -0,0 +1,106 @@ +// A client that predates accepted-send replies (every phone build) has its send reply held until +// handover. A message queued behind `/compact` is handed over only when the compaction ends, so +// that reply answers once the message waits behind the running command instead. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const COMPACTION_TURN = 'compaction-turn' +// Far under the phone's 15 s send timeout, and far over a handover that is already due. +const PROMPT_REPLY_MS = 2_000 + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The harness's default client is the phone's: pending replies, but not accepted-send. + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +function send(text: string): Promise<{ submission: AgentJournalSubmission }> { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + return harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function promptly(reply: Promise): Promise { + return Promise.race([ + reply, + new Promise((_, reject) => + setTimeout(() => reject(new Error('the send reply waited out the command')), PROMPT_REPLY_MS) + ) + ]) +} + +it('answers a send queued behind /compact at once, and delivers it once after the compaction', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await harness.ok('agentSession.conversationCommand', { + command: 'compact', + envelope: harness.envelope( + 'agentSession.conversationCommand', + { command: 'compact' }, + created.fence + ) + }) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const { submission } = await promptly(send('after the compaction')) + + expect(submission).toMatchObject({ dispatchState: 'pending' }) + expect(submission.handedOverAt).toBeUndefined() + expect(calls('turn/start')).toHaveLength(0) + + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: COMPACTION_TURN } }) + harness.codex.notify('item/completed', { + threadId: THREAD, + turnId: COMPACTION_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + harness.codex.notify('turn/completed', { + threadId: THREAD, + turn: { id: COMPACTION_TURN, status: 'completed' } + }) + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + const journal = await ( + await ensureStructuredAgentSessionHost(harness.hostConfig()) + ).journalSnapshot(SESSION) + expect( + journal.submissions.filter((entry) => entry.clientMessageId === submission.clientMessageId) + ).toEqual([expect.objectContaining({ handedOverAt: expect.any(Number) })]) + // Nothing re-sent it: the host delivered the one message once. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(calls('turn/start')).toHaveLength(1) +}) + +it('still holds the reply of a send that no command is holding until it is handed over', async () => { + await harness.ok('agentSession.create', harness.createIntentParams()) + + // The first send starts the child; the reply waits out that start and the handover. + const { submission } = await promptly(send('first message')) + + expect(submission.handedOverAt).toEqual(expect.any(Number)) + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) +}) diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 293ec194aae..c5aa604cb65 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -5,8 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { AGENT_SESSION_STORE_FILE_NAME } from './agent-session-record-store-file' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore, + testAgentSessionStoreFilePath +} from './agent-session-record-store-test-harness' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' const WORKSPACE = 'workspace-1' @@ -77,29 +80,17 @@ afterEach(async () => { describe('structured session rollback compatibility', () => { it('keeps the visible session reference through target → base → target', async () => { root = await mkdtemp(join(tmpdir(), 'orca-structured-rollback-')) - const storeDir = join(root, 'agent-store') - await mkdir(storeDir, { recursive: true }) const record = agentSessionRecordFixture({ ...agentSessionRecordFixture().lease, sessionId: SESSION, runtimeKind: 'native' }) - await writeFile( - join(storeDir, AGENT_SESSION_STORE_FILE_NAME), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: record }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) + await seedTestAgentSessionRecordStore(root, { records: [record] }) const journalDir = journalDirectoryFor(root, { workspaceId: WORKSPACE, sessionId: SESSION }) await mkdir(journalDir, { recursive: true }) await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n') - const target = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const target = await openTestAgentSessionRecordStore(root) expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) await target.setSessionTabVisibility(SESSION, true) expect(target.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [SESSION] }) @@ -126,18 +117,18 @@ describe('structured session rollback compatibility', () => { targetReloadedProfile?.success ? targetReloadedProfile.data : null ) ).toEqual([]) - const reloaded = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reloaded = await openTestAgentSessionRecordStore(root) expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( 'durable-journal-fixture\n' ) - expect( - JSON.parse(await readFile(join(storeDir, AGENT_SESSION_STORE_FILE_NAME), 'utf8')) - ).toMatchObject({ visibleSessionIds: [SESSION] }) + expect(JSON.parse(await readFile(testAgentSessionStoreFilePath(root), 'utf8'))).toMatchObject({ + visibleSessionIds: [SESSION] + }) await reloaded.setSessionTabVisibility(SESSION, false) - const afterClose = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const afterClose = await openTestAgentSessionRecordStore(root) expect(afterClose.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [] }) expect(afterClose.listVisibleSessionIds()).toEqual([]) expect(afterClose.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 90f4ff92c4b..47d93e02830 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -6,10 +6,13 @@ import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' export type InstalledRuntime = { host: StructuredAgentSessionHost adapter: { closeAll(): Promise } + /** Closed last, once every conversation has drained into it. */ + journalDatabase: JournalHostDatabase /** Resolves after every observed adapter exit has published, and every * recovery callback it raised has settled. */ waitForRecovery: () => Promise @@ -65,10 +68,13 @@ export async function tearDownRuntime( } catch (error) { failures.push(error) } + if (failures.length === 0) { + // After every sink drained and every conversation settled: nothing writes after this. + installed.journalDatabase.close() + return + } if (failures.length === 1) { throw failures[0] } - if (failures.length > 1) { - throw new AggregateError(failures, 'structured agent-session runtime teardown failed') - } + throw new AggregateError(failures, 'structured agent-session runtime teardown failed') } diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 9f4f4e2474d..3e2a1f14b86 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { agentSessionJournalCloseRetries } from '../native-chat/agent-session-journal/journal-close-retry' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { AgentSessionClaimStatus, AgentSessionExecutionLocation, @@ -263,69 +260,3 @@ describe('structured agent-session runtime install', () => { } }) }) - -// A stop whose teardown fails must not forget the runtime it was tearing down. -// `installing` is cleared either way so nothing new attaches, but the host keeps -// every journal whose close rejected, and this module slot is the only handle -// onto that host once it is gone. -describe('a teardown that fails is retried by the next stop', () => { - const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-teardown-retry', - workspaceId: 'ws-1', - hostId: HOST_ID, - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - const journals = createTrackedJournalOpener() - let directory: string | null = null - - afterEach(async () => { - await agentSessionJournalCloseRetries.retryAll() - await journals.closeAll() - await stopStructuredAgentSessionRuntime().catch(() => undefined) - if (directory) { - await rm(directory, { recursive: true, force: true }) - directory = null - } - }) - - it('reports the failure, then releases the handle on the following stop', async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) - await ensureStructuredAgentSessionHost({ - stateDirectory: directory, - hostId: HOST_ID, - claimKeyId: 'key-1', - resolveWorkspacePath: async () => directory!, - resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) - - const journalDir = join(directory, 'stubborn-journal') - const real = await journals.open({ identity: JOURNAL_IDENTITY, journalDir }) - let closeFailures = 2 - const flaky = new Proxy(real, { - get(target, property, receiver) { - if (property !== 'close') { - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. - return Reflect.get(target, property, receiver) - } - return async () => { - if (closeFailures > 0) { - closeFailures -= 1 - throw new Error('close rejected') - } - await target.close() - } - } - }) - await agentSessionJournalCloseRetries.closeOrRetain(flaky) - - // The host's teardown runs the registry retry, so this stop surfaces it. - await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([journalDir]) - - // The retained runtime is what makes this a retry rather than a no-op. - await stopStructuredAgentSessionRuntime() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) -}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 44656ec89ae..d022bac8144 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -1,11 +1,14 @@ // Where the structured agent-session wire becomes a live host on this runtime. // // Built on the first `agentSession.*` call rather than at startup: the record -// store and the journals live under the profile's user-data path, which is not +// store and the journal live under the profile's user-data path, which is not // final until Electron is ready, and a runtime that never serves a structured // session should not pay for a store it will never read. The slot the RPC layer // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. +// +// A process whose journal will not open installs none and answers every +// structured request with the refusal that says why. import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' @@ -37,7 +40,12 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import { + AGENT_SESSION_STORE_DIR_NAME, + agentSessionStorePath +} from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -53,20 +61,16 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -/** Sibling of the journal tree rather than inside it: one file adjudicates every - * session's lease, while a journal is per session. */ -const RECORD_STORE_DIR_NAME = 'agent-sessions' - export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { - const filePath = agentSessionStorePath(join(stateDirectory, RECORD_STORE_DIR_NAME)) + const filePath = agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) return fileExists(filePath) || fileExists(`${filePath}.bak`) } export type StructuredAgentSessionRuntimeDeps = { - /** Host state root. The record store and the journal tree both hang off it. */ + /** Host state root. The record store and the journal database both hang off it. */ stateDirectory: string /** Execution host this runtime *is*. A record pinned elsewhere is not ours to * probe and not ours to spawn for. */ @@ -119,9 +123,8 @@ export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every - * journal the host retained for a retry: `tearDownStructuredAgentSessionHost` - * deliberately keeps a failed close indexed, and only a later stop through this - * same runtime can reach those entries again. + * conversation the host kept indexed for a retry — and the journal connection, + * which closes only once they are settled — so a later stop retries them here. */ const pendingTeardown = new Set() @@ -151,7 +154,7 @@ export async function waitForStructuredAgentSessionRecovery(): Promise { * test isolation take the same path, so neither can leave a live app-server. * * A teardown that fails is RETRIED by the next stop rather than forgotten: the - * host keeps every journal whose close rejected, and this is the only handle + * host keeps every conversation it could not settle, and this is the only handle * onto that host once the module slot is cleared. */ export async function stopStructuredAgentSessionRuntime(options?: { trigger?: AgentSessionResumeTrigger @@ -191,10 +194,24 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers const store = await AgentSessionRecordStore.open({ - directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), + directory: join(deps.stateDirectory, AGENT_SESSION_STORE_DIR_NAME), hostId: deps.hostId }) let host: StructuredAgentSessionHost | null = null @@ -215,6 +232,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + void host + ?.releaseUnansweredDispatches({ + sessionId, + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + .catch((error) => + deps.onError?.({ + scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -231,19 +262,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, - onPrimaryThreadStoppedRunning: ({ sessionId }) => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - }, + onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, onEvent: (event) => { if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { lifecycle.deliver(event) @@ -270,6 +289,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, + onSessionIdle: releaseUnansweredDispatches, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore @@ -281,7 +301,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { }) function openStore(): Promise { - return AgentSessionRecordStore.open({ - directory: join(stateDirectory, 'agent-sessions'), + return openTestAgentSessionRecordStore(stateDirectory, { hostId: HOST_ID }) } diff --git a/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts b/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts new file mode 100644 index 00000000000..68756963193 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts @@ -0,0 +1,175 @@ +// A fake Codex app-server for the structured chat coordinator mail tests: it answers the JSON-RPC +// calls the adapter makes, and misbehaves on the knobs in `providerFaults`. + +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { isRecord } from './rpc/orchestration-session-caller-test-fixture' + +const WORKSPACE = 'workspace-1' + +export type FakeConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + threadId: string | null + turns: { clientUserMessageId: string; text: string }[] +} + +/** How the fake provider misbehaves; reset before each test. */ +export const providerFaults: { + dieBeforeEveryEcho: boolean + refuseTurnStarts: number + /** Refuses every start with this error while set. */ + refuseStart: (() => Error) | null + /** How long a start takes before it answers. */ + startDelayMs: number + /** Kills the app-server while it takes a turn: its exit and the failed call, in either order. */ + crashOnTurnStart: 'off' | 'exit-then-throw' | 'throw-then-exit' + starts: number + turnStarts: number +} = { + dieBeforeEveryEcho: false, + refuseTurnStarts: 0, + refuseStart: null, + startDelayMs: 0, + crashOnTurnStart: 'off', + starts: 0, + turnStarts: 0 +} + +export function fakeCodex() { + const connections: FakeConnection[] = [] + let turnCounter = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake answering the JSON-RPC calls the adapter makes, as the shipped integration test does. + const openConnection = (async (_launch, handlers = {}) => { + providerFaults.starts += 1 + if (providerFaults.startDelayMs) { + await new Promise((resolve) => setTimeout(resolve, providerFaults.startDelayMs)) + } + if (providerFaults.refuseStart) { + throw providerFaults.refuseStart() + } + const connection: FakeConnection = { + handlers, + threadId: null, + turns: [], + pid: 4321, + closed: false, + request: async (method, params) => { + const input = isRecord(params) ? params : {} + if (method === 'thread/start') { + connection.threadId = `thread-${connections.length}` + return { thread: { id: connection.threadId } } + } + if (method === 'thread/resume') { + connection.threadId = String(input.threadId) + return { thread: { id: connection.threadId } } + } + if (method === 'turn/start') { + providerFaults.turnStarts += 1 + if (providerFaults.crashOnTurnStart === 'exit-then-throw') { + connection.handlers.onExit?.(new Error('app-server crashed')) + throw new Error('connection closed') + } + if (providerFaults.crashOnTurnStart === 'throw-then-exit') { + setTimeout(() => connection.handlers.onExit?.(new Error('app-server crashed')), 0) + throw new Error('connection closed') + } + if (providerFaults.refuseTurnStarts > 0) { + providerFaults.refuseTurnStarts -= 1 + throw new Error('turn/start refused') + } + turnCounter += 1 + connection.turns.push({ + clientUserMessageId: String(input.clientUserMessageId), + text: JSON.stringify(input.input) + }) + if (providerFaults.dieBeforeEveryEcho) { + setTimeout(() => connection.handlers.onExit?.(new Error('provider died')), 0) + } + return { turn: { id: `turn-${turnCounter}` } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: 'medium', description: 'Balanced' }], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + return { connections, openConnection } +} + +let operations = 0 +export function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +export function attachParams(sessionId: string) { + const params = { + location: { + executionHostId: 'local' as const, + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const + } + const envelope = { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId, + fields: attachFingerprintFields({ ...params, envelope }) + }) + } + } +} + +/** A healthy provider and a fresh id sequence, before each test. */ +export function resetProviderFaults(): void { + operations = 0 + providerFaults.dieBeforeEveryEcho = false + providerFaults.refuseTurnStarts = 0 + providerFaults.refuseStart = null + providerFaults.startDelayMs = 0 + providerFaults.crashOnTurnStart = 'off' + providerFaults.starts = 0 + providerFaults.turnStarts = 0 +} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts new file mode 100644 index 00000000000..a36912fae95 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -0,0 +1,884 @@ +// A worker's result reaching the structured chat that coordinates it, end to end in one process. +// +// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the +// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the +// Codex app-server child, which answers the JSON-RPC calls the real one does. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' +import { refuse } from '../../shared/agent-session-wire-refusals' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { localOrchestrationCliCommand } from './orchestration/cli-command' +import { formatMessagePointer } from './orchestration/formatter' +import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' +import type { RpcRequest } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { + attachParams, + fakeCodex, + operationId, + providerFaults, + resetProviderFaults, + type FakeConnection +} from './structured-chat-coordinator-fake-codex-fixture' + +const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +let codex: ReturnType +let root: string +let runtime: OrcaRuntimeService +let db: OrchestrationDb +let host: StructuredAgentSessionHost +let dispatcher: RpcDispatcher +let requests = 0 + +function request( + method: string, + params: Record, + options: { sessionId?: string; capability?: string } = {} +): RpcRequest { + requests += 1 + return { + id: `rpc-${requests}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `req-${requests}`, + ...(options.sessionId + ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } + : {}), + ...(options.capability ? { orchestrationCapability: options.capability } : {}) + } +} + +async function call( + method: string, + params: Record, + options?: { sessionId?: string; capability?: string } +): Promise> { + const response = await dispatcher.dispatch(request(method, params, options)) + if (!response.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response)}`) + } + return resultOf(response) +} + +async function openChat(sessionId: string): Promise { + const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + await host.setSessionTabVisibility(sessionId, true) + threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) + return connectionFor(sessionId) +} + +const threadBySession = new Map() + +function connectionFor(sessionId: string): FakeConnection { + const connection = codex.connections.findLast( + (candidate) => candidate.threadId === threadBySession.get(sessionId) + ) + if (!connection) { + throw new Error(`no app-server for ${sessionId}`) + } + return connection +} + +/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */ +async function settleTurn(sessionId: string, turnIndex: number): Promise { + const connection = connectionFor(sessionId) + const turn = connection.turns[turnIndex]! + const turnId = `turn-${turnIndex + 1}` + const notify = (method: string, params: unknown) => + connection.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: turnId } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: `echo-${turn.clientUserMessageId}`, + clientId: turn.clientUserMessageId, + content: [{ type: 'text', text: 'pointer' }] + } + }) + notify('turn/completed', { turn: { id: turnId } }) + await host.flushStreamedEvents(sessionId) +} + +/** A user message typed into the chat, as the chat surface sends it. */ +function sendUserMessage(sessionId: string, text: string) { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }] + } + return host.send( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + }, + body + } + ) +} + +async function userTexts(sessionId: string): Promise { + return (await host.journalSnapshot(sessionId)).items.flatMap((item: AgentJournalRenderItem) => + item.body?.kind === 'message' && item.body.role === 'user' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')) + : [] + ) +} + +/** A capability-backed terminal worker under the coordinator's Run, and its worker_done. */ +async function finishWorker( + taskId: string, + worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } +): Promise { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: worker.handle, + paneKey: worker.paneKey, + processIncarnation: `runtime_test:${worker.handle}:1`, + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + await call( + 'orchestration.send', + { + from: worker.handle, + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) + }, + { capability } + ) +} + +async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { + const created = await call( + 'orchestration.runCreate', + { objective: 'ship' }, + { + sessionId: COORDINATOR + } + ) + const runId = idOf(created.run) + const task = await call( + 'orchestration.taskCreate', + { spec: 'build it' }, + { + sessionId: COORDINATOR + } + ) + return { runId, taskId: idOf(task.task) } +} + +/** `/clear` as the chat surface runs it: the conversation continues in a new session. */ +async function clearChat(sessionId: string): Promise { + const command = 'clear' as const + const cleared = await host.conversationCommand( + { callerKey: 'test-surface' }, + { + command, + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId, + fields: { command } + }) + } + } + ) + const successor = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!successor) { + throw new Error(`clear failed: ${JSON.stringify(cleared)}`) + } + // The surface swaps the tab over to the session that continues the chat. + await host.setSessionTabVisibility(sessionId, false) + await host.setSessionTabVisibility(successor, true) + threadBySession.set(successor, codex.connections.at(-1)!.threadId!) + return successor +} + +beforeEach(async () => { + resetProviderFaults() + root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) + codex = fakeCodex() + db = new OrchestrationDb(':memory:') + runtime = startRuntime() + host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + // The same call the runtime's own host install makes on every status change. + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) + }) + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) +}) + +/** The runtime over the shared database; a second call is what an Orca restart leaves behind. */ +function startRuntime(): OrcaRuntimeService { + const started = new OrcaRuntimeService() + started.setOrchestrationDb(db) + vi.spyOn(started, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null + ) + return started +} + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + db.close() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. +const WAIT = { timeout: 10_000 } + +const POINTER = + /You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./ + +/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */ +function ptyPointer(mailboxHandle: string): string { + return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim() +} + +/** The text of a turn the fake provider received. */ +function turnText(turn: { text: string }): string { + const input: unknown = JSON.parse(turn.text) + return Array.isArray(input) + ? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('') + : '' +} + +describe('a worker result reaches the structured chat that coordinates it', () => { + it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + + await finishWorker(taskId) + + // No user action: the result itself sends the chat a turn through the host's send. + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + expect(turnText(chat.turns[0]!)).toBe(ptyPointer(`run:${runId}`)) + await settleTurn(COORDINATOR, 0) + expect(await userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) + + const checked = await call('orchestration.check', {}, { sessionId: COORDINATOR }) + expect(checked).toMatchObject({ + runId, + count: 1, + messages: [{ type: 'worker_done', from_handle: 'term_worker' }] + }) + }) + + it('does not send a second pointer when the delivery is retried', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + + // A pending send is not an acknowledgement, so the mail is retained and retried on every edge + // until the host confirms it: before the echo, and again at the turn's idle edge. + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await settleTurn(COORDINATOR, 0) + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + expect(chat.turns).toHaveLength(1) + expect(await userTexts(COORDINATOR)).toHaveLength(1) + }) + + /** Fires both edges and waits until every gate read they started has answered. */ + async function edgesAnswered(): Promise { + const reads = vi.spyOn(host, 'journalSnapshot') + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: null }) + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) + await vi.waitFor(() => expect(reads).toHaveBeenCalled(), WAIT) + await Promise.all(reads.mock.results.map((read) => read.value)) + await new Promise((resolve) => setImmediate(resolve)) + reads.mockRestore() + } + + /** The operation ids the coordinator's journal recorded for its pointer turns. */ + async function pointerSends(): Promise { + const snapshot = await host.journalSnapshot(COORDINATOR) + return snapshot.submissions + .filter((submission) => + snapshot.items.some( + (item) => + item.itemId === agentJournalSubmissionKey(submission.clientMessageId) && + item.body?.kind === 'message' && + item.body.blocks.some((block) => block.type === 'text' && POINTER.test(block.text)) + ) + ) + .map((submission) => submission.clientMessageId) + } + + it('keeps a pointer whose provider died before the echo, and points it after the next turn that runs', async () => { + // A provider that dies before echoing never ran the pointer: the mail stays unpointed, and + // neither the death's own edge nor an idle one starts the provider again for it. + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + + chat.handlers.onExit?.(new Error('provider died before the echo')) + const before = codex.connections.length + await edgesAnswered() + await edgesAnswered() + expect(codex.connections.length).toBe(before) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + + // The user's next message starts the agent; once its turn runs, the pointer follows it. + expect(await sendUserMessage(COORDINATOR, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + expect(revived.turns[0]!.text).toContain('again') + await settleTurn(COORDINATOR, 0) + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(revived.turns[1]!.text).toMatch(POINTER) + await settleTurn(COORDINATOR, 1) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + }) + + it('does not restart a provider that dies before every echo, however many edges follow', async () => { + // What this pins: each death's own status edge used to re-point the mail, and that send started + // the provider again, about once a second for as long as the mail was unread. + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.dieBeforeEveryEcho = true + const before = codex.connections.length + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + // A fixed window, not a poll: a respawn loop would restart it several times in it. + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await edgesAnswered() + expect(codex.connections.length - before).toBe(0) + expect(providerFaults.turnStarts).toBe(1) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + }) + + it.each(['exit-then-throw', 'throw-then-exit'] as const)( + 'does not restart a provider that crashed while taking the pointer turn (%s)', + async (crash) => { + // The crash settles the send `unknown` with the connection's own error, not as a provider + // exit; every status edge after it re-pointed the mail and started the provider again. + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.crashOnTurnStart = crash + const before = providerFaults.starts + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await edgesAnswered() + expect(providerFaults.starts - before).toBe(0) + expect(providerFaults.turnStarts).toBe(1) + expect(await pointerSends()).toHaveLength(1) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + } + ) + + it('points the next result once after a transient death, then holds nothing', async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const second = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + providerFaults.dieBeforeEveryEcho = true + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + // The death was transient. A new result is new mail: one pointer for both, one start. + providerFaults.dieBeforeEveryEcho = false + const before = providerFaults.starts + await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) + const revived = connectionFor(COORDINATOR) + expect(turnText(revived.turns.at(-1)!)).toBe( + formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim() + ) + await settleTurn(COORDINATOR, revived.turns.length - 1) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + expect(providerFaults.starts - before).toBe(1) + expect(providerFaults.turnStarts).toBe(2) + }) + + it('leaves a pointer the person stopped while its agent was starting stopped', async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await host.close(COORDINATOR) + providerFaults.startDelayMs = 400 + const before = providerFaults.starts + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) + // The person presses Stop while the agent is still starting. + const cancelled = await host.cancel( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId: COORDINATOR, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: COORDINATOR, + fields: { turnId: 'turn-x' } + }) + }, + turnId: 'turn-x' + } + ) + expect(cancelled).toMatchObject({ ok: true }) + providerFaults.startDelayMs = 0 + // A fixed window, not a poll: a re-point would start the agent again in it. + await new Promise((resolve) => setTimeout(resolve, 1_500)) + expect(providerFaults.starts - before).toBe(1) + expect(await pointerSends()).toHaveLength(1) + await edgesAnswered() + expect(providerFaults.starts - before).toBe(1) + expect(providerFaults.turnStarts).toBe(0) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + }) + + it('points a held pointer once more after Orca restarts, under a new id', async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.dieBeforeEveryEcho = true + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + const [held] = await pointerSends() + + // The next process: a fresh runtime over the same database redrives restored mail. The + // provider still dies, so exactly one start proves it is pointed once, not in a loop. + runtime = startRuntime() + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const before = providerFaults.starts + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await edgesAnswered() + expect(providerFaults.starts - before).toBe(1) + expect(providerFaults.turnStarts).toBe(2) + const sends = await pointerSends() + expect(sends).toHaveLength(2) + expect(sends[0]).toBe(held) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + }) + + /** Mail for a chat whose agent is stopped and whose every start is refused; resolves after it. */ + async function refusedStartsFor( + refusal: () => Error + ): Promise<{ runId: string; starts: number }> { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await host.close(COORDINATOR) + providerFaults.refuseStart = refusal + const before = providerFaults.starts + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1), + WAIT + ) + // A fixed window, not a poll: a retry loop would start it several times in it. + await new Promise((resolve) => setTimeout(resolve, 1_500)) + return { runId, starts: providerFaults.starts - before } + } + + it.each([ + [ + 'one the person must fix', + () => new AgentSessionAcquisitionRefusal('no login', 'notSignedIn') + ], + [ + 'an account switch in progress', + () => + new AgentSessionPreSpawnError(new Error('switching accounts'), { + reason: 'accountSwitchInProgress' + }) + ] + ])( + 'holds mail after a start refused as %s until a turn runs, adding nothing on later edges', + async (_label, refusal) => { + const { runId, starts } = await refusedStartsFor(refusal) + expect(starts).toBe(1) + // Later edges replay the refusal: no start, and no new pointer and failure rows in the chat. + const before = providerFaults.starts + for (let edge = 0; edge < 5; edge += 1) { + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + await edgesAnswered() + expect(providerFaults.starts).toBe(before) + expect(await pointerSends()).toHaveLength(1) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + + // Fixed, the person's next message starts the agent, and the pointer follows its turn. + providerFaults.refuseStart = null + expect(await sendUserMessage(COORDINATOR, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns.length).toBeGreaterThanOrEqual(1), WAIT) + expect(revived.turns[0]!.text).toContain('again') + await settleTurn(COORDINATOR, 0) + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(revived.turns[1]!.text).toMatch(POINTER) + } + ) + + it('points held mail after the next turn that runs, even once a rewind dropped its send', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.crashOnTurnStart = 'exit-then-throw' + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + // What a rewind's recovery does: the journal is rebuilt, and no send is on record any more. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host keeps its conversations private; this is the one journal call rewind recovery makes. + const open = (host as unknown as { sessions: Map }) + .sessions + const fence = host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence + await open.get(COORDINATOR)!.journal.replaceEpochItems('handle_forked', fence, []) + expect(await pointerSends()).toEqual([]) + + providerFaults.crashOnTurnStart = 'off' + expect(await sendUserMessage(COORDINATOR, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) + const revived = connectionFor(COORDINATOR) + expect(revived).not.toBe(chat) + await settleTurn(COORDINATOR, revived.turns.length - 1) + await vi.waitFor(() => expect(turnText(revived.turns.at(-1)!)).toMatch(POINTER), WAIT) + await settleTurn(COORDINATOR, revived.turns.length - 1) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + }) + + it('points again under a new id once a send the host never recorded is too old to admit', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + // The first pointer is refused before the host records it, so the journal holds no verdict. + const realSend = host.send + const refused = vi.spyOn(host, 'send').mockImplementationOnce(async () => ({ + ok: false as const, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandInFlight' }, + 'busy' + ) + })) + refused.mockImplementation((caller, params) => realSend(caller, params)) + await finishWorker(taskId) + await vi.waitFor(() => expect(refused).toHaveBeenCalledTimes(1), WAIT) + const held = db.getStructuredPointerOperation(`run:${runId}`)?.operation_id + + // No edge for a day: the host would now refuse that id as expired, on every retry. + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(Date.now() + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 60_000) + await edgesAnswered() + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(chat.turns.map(turnText)).toEqual([ptyPointer(`run:${runId}`)]) + expect(db.getStructuredPointerOperation(`run:${runId}`)?.operation_id).not.toBe(held) + } finally { + vi.useRealTimers() + } + }) + + it('holds mail a refused turn left in doubt until the next result, then points it once', async () => { + // A failed turn/start cannot prove the turn never started, so the host records it `unknown` + // and a resend under its id replays that; new mail is a new send. + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const second = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + providerFaults.refuseTurnStarts = 1 + const before = codex.connections.length + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + expect(chat.turns).toHaveLength(0) + + await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + expect(turnText(chat.turns[0]!)).toBe( + formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim() + ) + expect(codex.connections.length).toBe(before) + }) + + it('points a coordinator whose agent is not running through the send alone, which starts it', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + // What the idle sweep leaves of a chat nobody is looking at: agent stopped, no map entry. + await host.close(COORDINATOR) + expect(host.hasSession(COORDINATOR)).toBe(false) + const before = codex.connections.length + + await finishWorker(taskId) + + // Nothing holds or wakes the session first: the pointer's accepted send starts its agent. + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + expect(revived.turns[0]!.text).toMatch(POINTER) + await settleTurn(COORDINATOR, 0) + expect(await userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) + }) + + it('points mail at the idle edge when it arrived mid-turn', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const first = await host.send( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId: COORDINATOR, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: COORDINATOR, + fields: { + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + }) + }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + ) + expect(first).toMatchObject({ ok: true }) + // Accepted, then delivered: the provider sees the turn once the host hands it over. + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + const notify = (method: string, params: unknown) => + chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: 'turn-1' } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: 'echo-go', + clientId: chat.turns[0]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(COORDINATOR) + + await finishWorker(taskId) + await new Promise((resolve) => setTimeout(resolve, 20)) + // The coordinator is mid-turn, so nothing is folded into that turn. + expect(chat.turns).toHaveLength(1) + + notify('turn/completed', { turn: { id: 'turn-1' } }) + await host.flushStreamedEvents(COORDINATOR) + await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) + expect(chat.turns[1]!.text).toMatch(POINTER) + expect(chat.turns[1]!.text).toContain(runId) + }) +}) + +describe('a /clear keeps the chat its orchestration address', () => { + it("delivers the conversation's Run to the session that continues it, and acts as it", async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const generation = db.getRunRaw(runId)!.consumer_generation + const successor = await clearChat(COORDINATOR) + const next = connectionFor(successor) + + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) + await finishWorker(taskId) + await vi.waitFor(() => expect(next.turns).toHaveLength(1), WAIT) + expect(next.turns[0]!.text).toMatch(POINTER) + await settleTurn(successor, 0) + await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + runId, + count: 1, + messages: [{ type: 'worker_done' }] + }) + // Nothing was rewritten: the Run is bound exactly as the first session bound it. + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_orca_session_id: COORDINATOR, + consumer_generation: generation + }) + }) + + it("stores the successor's own Run under the conversation's address, through a chain of clears", async () => { + await openChat(COORDINATOR) + const middle = await clearChat(COORDINATOR) + const created = await call( + 'orchestration.runCreate', + { objective: 'next' }, + { sessionId: middle } + ) + const runId = idOf(created.run) + expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) + const successor = await clearChat(middle) + runtime.onStructuredSessionStatusForMail({ sessionId: successor, status: 'idle' }) + + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) + expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) + // What it sends carries the same address. + await openChat(PEER_CHAT) + await expect( + call( + 'orchestration.send', + { to: `session:${PEER_CHAT}`, subject: 'hi' }, + { sessionId: successor } + ) + ).resolves.toMatchObject({ message: { from_handle: `session:${COORDINATOR}` } }) + }) + + it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { + const root = COORDINATOR + const boundOrcaSessionId = (runId: string): string | null => + currentRunCoordinatorOrcaSessionId(db.getRunRaw(runId)!) + await openChat(COORDINATOR) + const middle = await clearChat(COORDINATOR) + const first = idOf( + (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run + ) + expect(db.getRunRaw(first)).toMatchObject({ + coordinator_orca_session_id: root, + coordinator_orca_session_id_generation: db.getRunRaw(first)!.consumer_generation + }) + expect(boundOrcaSessionId(first)).toBe(root) + const second = idOf( + (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run + ) + expect(boundOrcaSessionId(first)).toBeNull() + + const successor = await clearChat(middle) + await call('orchestration.runUse', { id: first }, { sessionId: successor }) + const rebound = db.getRunRaw(first)! + expect(rebound.coordinator_orca_session_id).toBe(root) + expect(rebound.coordinator_orca_session_id_generation).toBe(rebound.consumer_generation) + expect(boundOrcaSessionId(second)).toBeNull() + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: first } }) + }) + + it('lands mail sent to any session of the conversation in the live one', async () => { + await openChat(PEER_CHAT) + const middle = await clearChat(PEER_CHAT) + const successor = await clearChat(middle) + const next = connectionFor(successor) + + for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) { + const sent = await call('orchestration.send', { + from: 'term_worker', + to: `session:${spelling}`, + subject: `ping ${index}` + }) + expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + await vi.waitFor(() => expect(next.turns).toHaveLength(index + 1), WAIT) + await settleTurn(successor, index) + } + await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + count: 3 + }) + }) +}) + +describe('any live session is addressable by its id', () => { + it('lands mail sent to `session:` as a turn in that chat, which a flagless check reads', async () => { + const peer = await openChat(PEER_CHAT) + + const sent = await call('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'ping' + }) + expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + + await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) + // Direct mail is not in a Run, so the pointer names no `--run`. + expect(turnText(peer.turns[0]!)).toBe(ptyPointer(`session:${PEER_CHAT}`)) + await settleTurn(PEER_CHAT, 0) + const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) + expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) + }) + + it('refuses mail to a chat that was closed, before storing it', async () => { + await openChat(PEER_CHAT) + await host.setSessionTabVisibility(PEER_CHAT, false) + const response = await dispatcher.dispatch( + request('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'ping' + }) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(db.getInbox(100)).toEqual([]) + }) +}) diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts index f018dfd30da..cc2b5c3b164 100644 --- a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -10,28 +9,11 @@ import { } from './structured-agent-session-runtime' /** - * The structured host's Claude auth policy has exactly one production wiring, and it - * lives in `orca-runtime-get-worktree-ps.ts` — a `@ts-nocheck` file, so neither the - * compiler nor a type test can see the field disappear. Deleting that wiring used to - * leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached the - * child, because `stripAuthEnv` silently fell back to `false`. - * - * Two independent guards replace that silence, and this file pins both. + * The structured host's Claude auth policy has exactly one production wiring. Deleting it + * used to leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached + * the child, because `stripAuthEnv` silently fell back to `false`. */ describe('structured Claude auth policy wiring', () => { - // The behavioural version of this assertion — importing the runtime class and - // capturing the installed deps — costs 35s of module transform for the whole - // OrcaRuntime chain (measured), so the wiring itself is pinned by source and the - // policy's meaning by claude-structured-auth-policy.test.ts. - it('passes a settings-derived Claude auth policy to the host installer', () => { - const source = readFileSync(join(__dirname, 'orca-runtime-get-worktree-ps.ts'), 'utf8') - - expect(source).toContain('claudeStructuredAuthPolicyForSettings') - expect(source).toMatch( - /resolveClaudeAuthPolicy:\s*\(\)\s*=>\s*\n?\s*claudeStructuredAuthPolicyForSettings\(/ - ) - }) - describe('installing without one', () => { let stateDirectory: string | null = null diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 38909e1a9bd..137b7ff6961 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -20,8 +20,10 @@ import { hostTestOperationId, resetHostTestOperationIds } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } const PROVIDER_SESSION_ID = claudeSessionIdForOrcaSession(HOST_TEST_SESSION) @@ -105,10 +107,7 @@ async function reattach() { beforeEach(async () => { resetHostTestOperationIds() directory = await mkdtemp(join(tmpdir(), 'orca-claude-pending-rewind-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(directory) claude = fakeClaude({ initSessionId: PROVIDER_SESSION_ID }) adapter = createStructuredClaudeRuntimeAdapter({ store, @@ -125,7 +124,7 @@ beforeEach(async () => { adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll() ), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index f1c0ddb72c9..d6aa6a7278e 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -35,6 +35,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] onLifecycleEvent: (event: StructuredAgentSessionLifecycleEvent) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] + onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] } @@ -59,6 +60,8 @@ export function structuredClaudeLifecycleEvent( cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, + // The instant the translator ended the open turn at; the host reads the exit's turn by it. + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) } } @@ -122,6 +125,7 @@ export function createStructuredClaudeRuntimeAdapter( } }, ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), + ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts new file mode 100644 index 00000000000..6b9024ba029 --- /dev/null +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -0,0 +1,278 @@ +// One structured Codex session driven over `agentSession.*`, with nothing stubbed but the Codex +// child: the RPC dispatcher, schemas, record store, journal, lease, Codex adapter and translation +// are the ones that ship. The fake app-server answers the JSON-RPC calls the real one does, and a +// test pushes notifications, blocking requests and the child's exit back through its handlers. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +export const SESSION = 'session-integration-1' +export const THREAD = 'thread-integration' +export const TURN = 'turn-1' +const WORKSPACE = 'workspace-1' +// Without the pending-send capability the host holds the reply until the send settles, which is a +// shim for clients too old to render a pending bubble — not what these suites model. +const DEFAULT_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] + +// `closed` is readonly on the real connection; the fake flips it so a test can +// see a takeover reap the previous child. +export type FakeCodexConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number }[] + resumedThreadId: string | null + launch: Parameters[0] +} + +export type FakeCodex = { + connections: FakeCodexConnection[] + openConnection: typeof openCodexAppServerConnection + live: () => FakeCodexConnection + notify: (method: string, params: unknown) => void + ask: (id: number, method: string, params: unknown) => void +} + +function fakeCodex(): FakeCodex { + const connections: FakeCodexConnection[] = [] + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { + const connection: FakeCodexConnection = { + launch, + handlers, + calls: [], + replies: [], + resumedThreadId: null, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + if (method === 'thread/start') { + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + } + if (method === 'thread/resume') { + connection.resumedThreadId = (params as { threadId: string }).threadId + return { thread: { id: connection.resumedThreadId } } + } + if (method === 'turn/start') { + return { turn: { id: TURN } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code) => connection.replies.push({ id, code }), + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + } + const live = (): FakeCodexConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no codex app-server has been opened') + } + return connection + } + return { + connections, + openConnection, + live, + notify: (method, params) => live().handlers.onNotification?.(method, params), + ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) + } +} + +function attachParams(operationId: () => string, fence: number | null) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const envelope = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +export type StructuredCodexRpcHarness = { + codex: FakeCodex + root: string + hostConfig: () => Parameters[0] + envelope: ( + method: string, + fields: Record, + fence: number | null + ) => { sessionId: string; clientOperationId: string; expectedRuntimeFence: number | null } + createIntentParams: () => Record + /** Runs a one-shot method and returns its decoded reply. */ + call: (method: string, params: unknown) => Promise + /** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ + ok: (method: string, params: unknown) => Promise + dispose: () => Promise +} + +export async function openStructuredCodexRpcHarness( + clientCapabilities: readonly RuntimeCapability[] = DEFAULT_CLIENT_CAPABILITIES +): Promise { + const client = { clientId: 'device-a', clientKind: 'runtime' as const, clientCapabilities } + let operations = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) + const codex = fakeCodex() + // `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real time, not a frozen + // constant: the runtime under test stamps the ledger with its own clock and refuses a future id. + const operationId = (): string => { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` + } + const envelope: StructuredCodexRpcHarness['envelope'] = (method, fields, fence) => ({ + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const hostConfig = (): Parameters[0] => ({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ + PATH: '/shell/bin:/usr/bin', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home' + }), + resolveCodexOverrides: () => ({ CODEX_PROFILE: 'configured' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const runtime = { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(operationId, null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost(hostConfig()).then(() => undefined), + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ + releaseIfCurrent: dispose + })) + } + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) + const call: StructuredCodexRpcHarness['call'] = async (method, params) => { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), client) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first + } + return { + codex, + root, + hostConfig, + envelope, + createIntentParams: () => { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } + }, + call, + ok: async (method: string, params: unknown): Promise => { + const response = await call(method, params) + expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result + expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ + ok: true + }) + return result.value as T + }, + dispose: async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + } + } +} diff --git a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts new file mode 100644 index 00000000000..d7779112cc6 --- /dev/null +++ b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts @@ -0,0 +1,65 @@ +// The production wiring of the idle-edge mail redrive: the host the runtime installs must report +// every status change to the runtime's mail redrive. The integration test installs its own callback, +// so without this nothing pins the line that connects the two in the real app. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type * as StructuredAgentSessionRuntime from './structured-agent-session-runtime' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +const installed = vi.hoisted((): { deps: StructuredAgentSessionRuntimeDeps | null } => ({ + deps: null +})) + +vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + ensureStructuredAgentSessionHost: vi.fn(async (deps: StructuredAgentSessionRuntimeDeps) => { + installed.deps = deps + return {} + }) +})) + +const { OrcaRuntimeService } = await import('./orca-runtime') +const { OrchestrationDb } = await import('./orchestration/db') + +describe("the runtime's own structured host install", () => { + it('reports every session status change to the mail redrive', async () => { + const runtime = new OrcaRuntimeService() + const redrive = vi + .spyOn(runtime, 'onStructuredSessionStatusForMail') + .mockImplementation(() => {}) + await runtime.ensureStructuredAgentSessionHost() + const summary: AgentSessionStatusSummary = { + sessionId: 'claude_1234abcd', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'idle', + latestPrompt: '', + updatedAt: 0 + } + try { + installed.deps?.onSessionStatusChanged?.(summary, { replay: false }) + } catch { + // The same callback's rename half needs a store this bare runtime does not have. + } + expect(redrive).toHaveBeenCalledWith(summary) + }) + + it('logs a redrive the database fails, so the status callback goes on to the workspace rename', () => { + const runtime = new OrcaRuntimeService() + const closed = new OrchestrationDb(':memory:') + closed.close() + vi.spyOn(runtime, 'getExistingOrchestrationDb').mockReturnValue(closed) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => + runtime.onStructuredSessionStatusForMail({ + sessionId: '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37', + status: 'idle' + }) + ).not.toThrow() + expect(warn).toHaveBeenCalledWith( + '[orchestration] structured session mail redrive failed', + expect.objectContaining({ sessionId: '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' }) + ) + }) +}) diff --git a/src/main/runtime/structured-worker-terminal-read.ts b/src/main/runtime/structured-worker-terminal-read.ts index 60dd1126067..3d31c4c863c 100644 --- a/src/main/runtime/structured-worker-terminal-read.ts +++ b/src/main/runtime/structured-worker-terminal-read.ts @@ -29,7 +29,7 @@ */ import type { RuntimeTerminalRead } from '../../shared/runtime-types' -import { formatWorkerTranscriptMessages } from '../../shared/worker-transcript-text' +import { formatWorkerTranscriptMessage } from '../../shared/worker-transcript-text' import { AGENT_SESSION_NOT_ATTACHED } from '../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import type { OrchestrationDb } from './orchestration/db' import { boundStructuredJournalTail } from './orchestration/structured-worker-journal-archive' @@ -84,7 +84,9 @@ export async function readStructuredWorkerTerminal(args: { } // Redacts dispatch capabilities and clips oversized blocks under the archive path's byte bound. const bounded = boundStructuredJournalTail(page.items) - const lines = formatWorkerTranscriptMessages(bounded.messages).flatMap((text) => text.split('\n')) + const lines = bounded.messages.flatMap((message) => + formatWorkerTranscriptMessage(message).split('\n') + ) const read = readTerminalTail({ handle: args.handle, status: structuredWorkerTerminalState(observeStructuredWorker(identity).status), diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts index be33324d81e..8439c985b2e 100644 --- a/src/main/runtime/terminal-view-attribute-store.ts +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -4,21 +4,30 @@ * push. One app-global snapshot, not per-PTY — per-pane font zoom never * affects these attributes and the color/cursor settings are global. * - * Null until the first push, and the responder answers NO view-attribute - * query while null (silent-until-first-push): a fabricated default would - * resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC - * hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip. + * Null until the first push; the hidden-pane model responder answers its + * OSC 4/12 and ?996n queries only once it is set. OSC 10/11 never reach it + * from a current PTY owner, which answers them itself from the viewer colours + * below. Staleness is bounded by one IPC hop; subscribed TUIs are corrected by + * the renderer-owned 2031/997 flip. */ import { terminalViewAttributesEqual, + terminalViewColorQueryReplyColors, type TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' +import { colorQueryReplyColorsEqual } from '../../shared/pty-owner-color-query-colors' // Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives // the push, the runtime emulators consult it at reply time via the getter. let currentAttributes: TerminalViewAttributes | null = null +// Why one value for every pane: the host shows the same panes to every viewer. This host's own +// window answers when it has one; a paired client's push answers only on a headless host. +let pairedViewerColors: TerminalOscColorQueryReplyColors | null = null +let seedColors: TerminalOscColorQueryReplyColors | null = null +let viewerColorsListener: ((colors: TerminalOscColorQueryReplyColors) => void) | null = null + // Why appliers (pattern of registerConptyDa1OverrideInstaller): each push // must also reach already-live emulators — cursor options under the replay // guard, plus the per-PTY override reset a theme apply implies. @@ -41,32 +50,61 @@ export function setTerminalViewAttributes(attributes: TerminalViewAttributes): v if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) { return } + const before = getTerminalViewerColors() currentAttributes = attributes for (const applier of pushAppliers) { applier(attributes) } + notifyIfViewerColorsChanged(before) } export function getTerminalViewAttributes(): TerminalViewAttributes | null { return currentAttributes } -function rgbToCssHex(rgb: readonly [number, number, number]): string { - return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}` +export function getTerminalViewColorQueryReplyColors(): TerminalOscColorQueryReplyColors | null { + return currentAttributes ? terminalViewColorQueryReplyColors(currentAttributes) : null } -export function getTerminalViewColorQueryReplyColors(): TerminalOscColorQueryReplyColors | null { - if (!currentAttributes) { - return null - } - return { - foreground: rgbToCssHex(currentAttributes.foreground), - background: rgbToCssHex(currentAttributes.background) +/** The colours OSC 10/11 answer with on this host: its own window, else a paired client, else + * the saved theme. */ +export function getTerminalViewerColors(): TerminalOscColorQueryReplyColors | null { + return getTerminalViewColorQueryReplyColors() ?? pairedViewerColors ?? seedColors +} + +/** A paired client's theme, from terminal.setViewerColors or the colours on terminal.create. */ +export function setPairedViewerColors(colors: TerminalOscColorQueryReplyColors): void { + const before = getTerminalViewerColors() + pairedViewerColors = colors + notifyIfViewerColorsChanged(before) +} + +/** The saved theme, answering until a viewer reports its colours. */ +export function seedTerminalViewerColors(colors: TerminalOscColorQueryReplyColors): void { + const before = getTerminalViewerColors() + seedColors = colors + notifyIfViewerColorsChanged(before) +} + +function notifyIfViewerColorsChanged(before: TerminalOscColorQueryReplyColors | null): void { + const colors = getTerminalViewerColors() + if (colors && !colorQueryReplyColorsEqual(before, colors)) { + viewerColorsListener?.(colors) } } +/** One listener: the PTY IPC layer re-installs it on macOS re-activation. */ +export function setTerminalViewerColorsListener( + listener: ((colors: TerminalOscColorQueryReplyColors) => void) | null +): void { + viewerColorsListener = listener +} + /** Test seam: reset module state between tests. */ export function _resetTerminalViewAttributesForTest(): void { currentAttributes = null pushAppliers.clear() + pairedViewerColors = null + seedColors = null + viewerColorsListener = null } diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 1540eef2d5f..1c05c697fb1 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -9,9 +9,12 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import type { TuiAgent } from '../../shared/tui-agent' import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' import { + findCodexHeaderIndex, findCodexScreenReadyPromptIndex, - isCodexComposerReadyScreen + isCodexComposerReadyScreen, + isCodexProvisionalStartupText } from './codex-terminal-readiness' +import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals' import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i @@ -52,24 +55,43 @@ export function isKnownReadyPromptPreview(preview: string): boolean { return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) } +/** + * The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview + * (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed + * behind it while its daemon starts. + */ +export function isKnownReadyPromptSettled(preview: string): boolean { + const normalized = preview.toLowerCase() + return isReadyPromptSettled(normalized, findKnownReadyPromptIndex(normalized)) +} + +function isReadyPromptSettled(normalized: string, readyIndex: number | null): boolean { + return ( + isReadyPromptUnblocked(normalized, readyIndex) && !isCodexProvisionalStartupText(normalized) + ) +} + /** * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's * visible grid, or null when the runtime has no trustworthy one. * - * Why the screen: Codex repaints its header by cell diff (`ESC[5;3Hdir ESC[5;7Hctory:`), which - * only a grid reassembles — the line-folded wait text reads `dirctory:` forever. - * Why it can only add readiness: a grid out of step with the PTY (size mismatch, resize - * mid-paint) garbles the header, so the text rules keep every verdict they give today. + * Why not for a clocked Codex pane: its header stays up through every turn, so it proves only + * that Codex started; isQuietReadyScreenBody holds it to quiescence instead. + * Why a clockless pane keeps it: quiescence needs an output clock, which a restored pane lacks. */ export function isKnownReadyPromptBody( waitText: string, agent: TuiAgent | null, - readScreenLines: () => readonly string[] | null + readScreenLines: () => readonly string[] | null, + hasOutputClock: boolean ): boolean { if (agent === 'qoder') { return isQoderComposerReady(readScreenLines()) } - if (isKnownReadyPromptPreview(waitText)) { + if (agent === 'codex' && hasOutputClock) { + return false + } + if (isKnownReadyPromptSettled(waitText)) { return true } // Why the agent gate: another agent's screen can merely mention "OpenAI Codex". @@ -77,7 +99,7 @@ export function isKnownReadyPromptBody( return false } const screen = readScreen(readScreenLines) - return screen !== null && isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) + return screen !== null && isCodexScreenHeaderReady(screen) } /** @@ -92,11 +114,29 @@ export function isQuietReadyScreenBody( ): boolean { if (agent === 'codex') { const screen = readScreen(readScreenLines) - return screen !== null && isCodexComposerReadyScreen(screen) + if ( + screen !== null && + (isCodexComposerReadyScreen(screen) || isCodexScreenHeaderReady(screen)) + ) { + return true + } + // Why the provisional veto here too: a daemon start can stay quiet past the quiescence window. + const normalized = waitText.toLowerCase() + return isReadyPromptSettled(normalized, findCodexReadyPromptIndex(normalized)) } return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText) } +/** + * Why the screen: Codex repaints its 0.150-0.157 header by cell diff (`ESC[5;3Hdir + * ESC[5;7Hctory:`), which only a grid reassembles — the line-folded wait text reads `dirctory:`. + * Why it can only add readiness: a grid out of step with the PTY (size mismatch, resize + * mid-paint) garbles the header, so the text rule keeps every verdict it gives on its own. + */ +function isCodexScreenHeaderReady(screen: string): boolean { + return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) +} + function readScreen(readScreenLines: () => readonly string[] | null): string | null { return readScreenLines()?.join('\n').toLowerCase() ?? null } @@ -139,6 +179,7 @@ export function findActionableTerminalWaitBlockedSignal( function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), + findCodexHeaderIndex(normalized), findAntigravityReadyPromptIndex(normalized), findCursorActivePromptIndex(normalized), findMuseReadyPromptIndex(normalized) @@ -199,7 +240,7 @@ function findCodexReadyPromptIndex(normalized: string): number | null { } export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i + /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i // Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. const CURSOR_APPROVAL_CHOICE_MARKERS = [ @@ -268,27 +309,7 @@ function findTerminalWaitBlockedSignal( function findBlockedSignalInLiveWindow( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. - candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) - } + const candidates = findStartupDialogBlockedSignals(normalized) const trustIndex = Math.max( normalized.lastIndexOf('do you trust'), normalized.lastIndexOf('trust this'), diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index ee8e908c61b..818d9654510 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -196,10 +196,15 @@ describe('rest signal agrees with the lanes that can settle a wait', () => { const hookDone = hasFreshDoneFirstPartyStatus(agent, { state: 'done', updatedAt: Date.now() }) expect(hookDone).toBe(signal === 'hook-done') let screenRead = false - isKnownReadyPromptBody('', agent, () => { - screenRead = true - return null - }) + isKnownReadyPromptBody( + '', + agent, + () => { + screenRead = true + return null + }, + false + ) const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS) // Why not only ready-body: Codex keeps its stronger hook-driven title beside this lane. if (quietScreenBody) { diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index cb7dc13714b..064477ae71c 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -329,7 +329,12 @@ export function leafTuiIdleEvidence( readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), + isKnownReadyPromptBody( + waitText(), + agent, + () => source.readScreenLines(leaf.ptyId), + leaf.lastOutputAt !== null + ), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), agent, @@ -350,7 +355,12 @@ export function ptyTuiIdleEvidence( readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || - isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), + isKnownReadyPromptBody( + waitText(), + agent, + () => source.readScreenLines(pty.ptyId), + pty.lastOutputAt !== null + ), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), agent, diff --git a/src/main/ssh/relay-protocol-backpressure.test.ts b/src/main/ssh/relay-protocol-backpressure.test.ts deleted file mode 100644 index 45a869af16e..00000000000 --- a/src/main/ssh/relay-protocol-backpressure.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - FrameDecoder, - FrameDecoderContinuationError, - FRAME_DECODER_MAX_RETAINED_BYTES, - HEADER_LENGTH, - MAX_MESSAGE_SIZE, - MessageType, - encodeFrame, - type DecodedFrame -} from './relay-protocol' - -function createScheduler(): { - schedule: (callback: () => void) => number - cancel: (handle: unknown) => void - runNext: () => void - pending: () => number -} { - let nextHandle = 1 - const callbacks = new Map void>() - return { - schedule: (callback) => { - const handle = nextHandle++ - callbacks.set(handle, callback) - return handle - }, - cancel: (handle) => callbacks.delete(handle as number), - runNext: () => { - const entry = callbacks.entries().next().value as [number, () => void] | undefined - if (!entry) { - throw new Error('No decoder continuation scheduled') - } - callbacks.delete(entry[0]) - entry[1]() - }, - pending: () => callbacks.size - } -} - -function frame(id: number, payload = `${id}`): Buffer { - return encodeFrame(MessageType.Regular, id, 0, Buffer.from(payload)) -} - -describe('FrameDecoder bounded turns', () => { - it('retains at most one maximum frame plus one MiB of partial input', () => { - expect(FRAME_DECODER_MAX_RETAINED_BYTES).toBe(MAX_MESSAGE_SIZE + HEADER_LENGTH + 1024 * 1024) - const maximumFrame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(MAX_MESSAGE_SIZE)) - const acceptedError = vi.fn() - const accepted = new FrameDecoder(vi.fn(), acceptedError) - - accepted.feed(Buffer.concat([maximumFrame, Buffer.alloc(1024 * 1024)])) - - expect(acceptedError).not.toHaveBeenCalled() - expect(accepted.drain()).toHaveLength(1024 * 1024) - - const excessError = vi.fn() - const excess = new FrameDecoder(vi.fn(), excessError) - excess.feed(Buffer.concat([maximumFrame, Buffer.alloc(1024 * 1024 + 1)])) - - expect(excessError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('retained-input') }) - ) - expect(excess.drain()).toHaveLength(0) - }) - - it('fails closed when one delivered chunk exceeds retained-input capacity', () => { - const onError = vi.fn() - const decoder = new FrameDecoder(vi.fn(), onError) - - decoder.feed(Buffer.alloc(FRAME_DECODER_MAX_RETAINED_BYTES + 1)) - - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('retained-input') }) - ) - expect(decoder.drain()).toHaveLength(0) - }) - - it('emits the first frame synchronously and preserves order through self-pause', () => { - const scheduler = createScheduler() - const seen: number[] = [] - let decoder: FrameDecoder - const pause = vi.fn(() => decoder.feed(frame(4))) - const resume = vi.fn() - decoder = new FrameDecoder((decoded) => seen.push(decoded.id), undefined, { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: scheduler.cancel, - pause, - resume - }) - - decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)])) - - expect(seen).toEqual([1]) - expect(pause).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(1) - - scheduler.runNext() - scheduler.runNext() - scheduler.runNext() - - expect(seen).toEqual([1, 2, 3, 4]) - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - }) - - it('bounds decoded bytes and time independently from the frame count', () => { - const byteScheduler = createScheduler() - const byteSeen: number[] = [] - const first = frame(1, 'one') - const second = frame(2, 'two') - const byteDecoder = new FrameDecoder((decoded) => byteSeen.push(decoded.id), undefined, { - maxFramesPerTurn: 64, - maxBytesPerTurn: first.length, - schedule: byteScheduler.schedule, - cancelScheduled: byteScheduler.cancel - }) - - byteDecoder.feed(Buffer.concat([first, second])) - expect(byteSeen).toEqual([1]) - byteScheduler.runNext() - expect(byteSeen).toEqual([1, 2]) - - const timeScheduler = createScheduler() - const timeSeen: number[] = [] - let nowCalls = 0 - const timeDecoder = new FrameDecoder((decoded) => timeSeen.push(decoded.id), undefined, { - maxFramesPerTurn: 64, - maxBytesPerTurn: MAX_MESSAGE_SIZE + HEADER_LENGTH, - maxTurnMs: 4, - now: () => (nowCalls++ === 0 ? 0 : 5), - schedule: timeScheduler.schedule, - cancelScheduled: timeScheduler.cancel - }) - - timeDecoder.feed(Buffer.concat([frame(3), frame(4)])) - expect(timeSeen).toEqual([3]) - timeScheduler.runNext() - expect(timeSeen).toEqual([3, 4]) - }) - - it('releases its pause epoch when continuation scheduling throws', () => { - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder(() => {}, undefined, { - maxFramesPerTurn: 1, - pause, - resume, - schedule: () => { - throw new Error('scheduler unavailable') - } - }) - - expect(() => decoder.feed(Buffer.concat([frame(1), frame(2)]))).toThrow('scheduler unavailable') - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - decoder.reset() - expect(resume).toHaveBeenCalledTimes(1) - }) - - it('contains a throwing continuation, resets residue, and reports one typed error', () => { - const scheduler = createScheduler() - const seen: number[] = [] - const onError = vi.fn() - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder( - (decoded) => { - if (decoded.id === 2) { - throw new Error('frame owner failed') - } - seen.push(decoded.id) - }, - onError, - { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: scheduler.cancel, - pause, - resume - } - ) - - decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)])) - expect(() => scheduler.runNext()).not.toThrow() - - expect(seen).toEqual([1]) - expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(FrameDecoderContinuationError)) - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - name: 'FrameDecoderContinuationError', - cause: expect.objectContaining({ message: 'frame owner failed' }) - }) - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - expect(decoder.drain()).toHaveLength(0) - - decoder.feed(frame(4)) - expect(seen).toEqual([1, 4]) - }) - - // feed() runs straight from a transport data handler. A frame owner that threw on the first - // turn used to escape feed() and reach uncaughtException. The continuation path was already - // contained; the synchronous path must match it. - it('contains a frame owner that throws on the synchronous turn and reports one typed error', () => { - const seen: number[] = [] - const onError = vi.fn() - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder( - (decoded) => { - if (decoded.id === 2) { - throw new Error('frame owner failed') - } - seen.push(decoded.id) - }, - onError, - { pause, resume } - ) - - expect(() => decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)]))).not.toThrow() - - expect(seen).toEqual([1]) - expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(FrameDecoderContinuationError)) - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ message: 'frame owner failed' }) - }) - expect(decoder.drain()).toHaveLength(0) - expect(pause).not.toHaveBeenCalled() - expect(resume).not.toHaveBeenCalled() - - decoder.feed(frame(4)) - expect(seen).toEqual([1, 4]) - }) - - it('keeps reads active for partial frames and incrementally discards oversized payloads', () => { - const errors: Error[] = [] - const seen: DecodedFrame[] = [] - const pause = vi.fn() - const decoder = new FrameDecoder( - (decoded) => seen.push(decoded), - (error) => errors.push(error), - { pause } - ) - const valid = frame(2, 'complete') - - decoder.feed(valid.subarray(0, HEADER_LENGTH + 2)) - expect(seen).toHaveLength(0) - expect(pause).not.toHaveBeenCalled() - decoder.feed(valid.subarray(HEADER_LENGTH + 2)) - expect(seen.map(({ id }) => id)).toEqual([2]) - - const oversizedHeader = Buffer.alloc(HEADER_LENGTH) - oversizedHeader[0] = MessageType.Regular - oversizedHeader.writeUInt32BE(3, 1) - oversizedHeader.writeUInt32BE(MAX_MESSAGE_SIZE + 1, 9) - decoder.feed(Buffer.concat([oversizedHeader, Buffer.alloc(32)])) - - expect(errors).toHaveLength(1) - expect(pause).not.toHaveBeenCalled() - decoder.reset() - decoder.feed(frame(4, 'after-reset')) - expect(seen.map(({ id }) => id)).toEqual([2, 4]) - }) - - it('drain and reset cancel continuation ownership without replaying residue', () => { - const scheduler = createScheduler() - const seen: number[] = [] - const resume = vi.fn() - const cancel = vi.fn(scheduler.cancel) - const decoder = new FrameDecoder((decoded) => seen.push(decoded.id), undefined, { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: cancel, - resume - }) - const second = frame(2, 'residue') - - decoder.feed(Buffer.concat([frame(1), second])) - const residue = decoder.drain() - - expect(seen).toEqual([1]) - expect(residue.equals(second)).toBe(true) - expect(cancel).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - - decoder.feed(Buffer.concat([frame(3), frame(4)])) - decoder.reset() - expect(cancel).toHaveBeenCalledTimes(2) - expect(scheduler.pending()).toBe(0) - - decoder.feed(frame(5)) - expect(seen).toEqual([1, 3, 5]) - }) -}) diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 7dc5ed8368d..5617745ed50 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -555,12 +555,6 @@ describe('SshChannelMultiplexer', () => { expect(disposeHandler).toHaveBeenCalledTimes(1) }) - it('ignores notify after dispose', () => { - mux.dispose() - mux.notify('pty.data', { id: 'pty-1', data: 'x' }) - // No writes should happen after the initial keepalive writes - }) - it('reports isDisposed correctly', () => { expect(mux.isDisposed()).toBe(false) mux.dispose() diff --git a/src/main/ssh/ssh-connection-manager-registry.test.ts b/src/main/ssh/ssh-connection-manager-registry.test.ts index 046c6f9f82c..e6a6bc24843 100644 --- a/src/main/ssh/ssh-connection-manager-registry.test.ts +++ b/src/main/ssh/ssh-connection-manager-registry.test.ts @@ -57,11 +57,6 @@ describe('SshConnectionManager', () => { expect(mgr.getConnection(target.id)).toBeUndefined() }) - it('disconnect is a no-op for unknown targets', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - await mgr.disconnect('unknown') - }) - it('reuses existing connected connection for same target', async () => { const mgr = new SshConnectionManager(createCallbacks()) const target = createTarget() diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 00abeeb2052..296c89b1d98 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -29,36 +29,12 @@ import { shellEscape, findDefaultKeyFile, buildConnectConfig, - resolveAgentSocket, - CONNECT_TIMEOUT_MS, - INITIAL_RETRY_ATTEMPTS, - INITIAL_RETRY_DELAY_MS, - RECONNECT_BACKOFF_MS + resolveAgentSocket } from './ssh-connection-utils' import { resolveEffectiveProxy } from './ssh-proxy-command' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' -// ── Constants ──────────────────────────────────────────────────────── - -describe('SSH connection constants', () => { - it('CONNECT_TIMEOUT_MS is 30 seconds (matches VS Code)', () => { - expect(CONNECT_TIMEOUT_MS).toBe(30_000) - }) - - it('INITIAL_RETRY_ATTEMPTS is 5', () => { - expect(INITIAL_RETRY_ATTEMPTS).toBe(5) - }) - - it('INITIAL_RETRY_DELAY_MS is 2 seconds', () => { - expect(INITIAL_RETRY_DELAY_MS).toBe(2000) - }) - - it('RECONNECT_BACKOFF_MS has 9 entries', () => { - expect(RECONNECT_BACKOFF_MS).toHaveLength(9) - }) -}) - // ── isTransientError ───────────────────────────────────────────────── describe('isTransientError', () => { diff --git a/src/main/ssh/ssh-reconnect-ladder.test.ts b/src/main/ssh/ssh-reconnect-ladder.test.ts index c43d4e9e6e9..8010ebd96a5 100644 --- a/src/main/ssh/ssh-reconnect-ladder.test.ts +++ b/src/main/ssh/ssh-reconnect-ladder.test.ts @@ -58,7 +58,6 @@ describe('SshReconnectLadder', () => { now += decision.delayMs } } - expect(FLAP_DELAY_CAP_MS).toBe(5_000) }) it('keeps the uncapped table for a host whose handshakes fail', () => { diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 363a87a645d..758d4739b0f 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -20,13 +20,11 @@ import { tryStealInstallLockCommand } from './ssh-relay-install-lock-commands' import { - commandInRemoteDirectory, commandWithNodePath, listRelayBaseDirsCommand, MAX_RELAY_GC_LISTING_ENTRIES, makeRemoteDirectoryCommand, moveRemoteTreeCommand, - promoteRemoteTreeContentsCommand, probeDirectoryExistsCommand, probeRelayInstalledCommand, readRemoteHomeCommand, @@ -229,29 +227,6 @@ describe('ssh remote command builders', () => { expect(windowsScript).toContain("'MOVED'") }) - it('enumerates Windows staging children before copying', () => { - const script = decodePowerShellCommand( - promoteRemoteTreeContentsCommand(windows, 'C:/Users/me/relay.upload-123', 'C:/Users/me/relay') - ) - expect(script).toContain('Get-ChildItem -LiteralPath') - expect(script).toContain(' -Force -ErrorAction Stop | Copy-Item -Destination') - expect(script).not.toContain('Copy-Item -LiteralPath') - expect(script).toContain('Remove-Item -LiteralPath') - expect(script).toContain("$ErrorActionPreference = 'Stop'") - expect(script).toContain('Copy-Item -Destination') - }) - - it('removes POSIX staging only after the copy succeeds', () => { - const command = promoteRemoteTreeContentsCommand( - posix, - '/home/u/relay.upload-123', - '/home/u/relay' - ) - expect(command).toContain("cp -a '/home/u/relay.upload-123'/. '/home/u/relay'/") - expect(command).toContain("&& rm -rf '/home/u/relay.upload-123'") - expect(command.indexOf('cp -a')).toBeLessThan(command.indexOf('rm -rf')) - }) - it('emits an explicit POSIX liveness result so GC can fail closed', () => { const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') @@ -659,9 +634,6 @@ describe('ssh remote command builders', () => { ) it('makes Windows remote directory changes fail before running scoped commands', () => { - const scopedCommand = decodePowerShellCommand( - commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'") - ) const nodeScopedCommand = decodePowerShellCommand( commandWithNodePath( windows, @@ -671,9 +643,6 @@ describe('ssh remote command builders', () => { ) ) - expect(scopedCommand).toContain( - "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me/.orca-remote/relay-0.1.0'" - ) expect(nodeScopedCommand).toContain( "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me/.orca-remote/relay-0.1.0'" ) diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index f23a7c4c689..11e39921fc2 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -86,19 +86,6 @@ export function restoreRemoteTreeCommand( return `if [ ! -e ${shellEscape(destination)} ] && [ ! -L ${shellEscape(destination)} ]; then mv ${shellEscape(source)} ${shellEscape(destination)} && echo MOVED; else echo BUSY; fi` } -export function promoteRemoteTreeContentsCommand( - host: RemoteHostPlatform, - sourcePath: string, - destinationPath: string -): string { - if (!isWindowsRemoteHost(host)) { - return `cp -a ${shellEscape(sourcePath)}/. ${shellEscape(destinationPath)}/ && rm -rf ${shellEscape(sourcePath)}` - } - return powerShellCommand( - `$ErrorActionPreference = 'Stop'; Get-ChildItem -LiteralPath ${powerShellLiteral(sourcePath)} -Force -ErrorAction Stop | Copy-Item -Destination ${powerShellLiteral(destinationPath)} -Recurse -Force -ErrorAction Stop; Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Recurse -Force -ErrorAction Stop` - ) -} - export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { return `touch ${shellEscape(remotePath)}` @@ -277,19 +264,6 @@ export function relayLivenessProbeCommand( ) } -export function commandInRemoteDirectory( - host: RemoteHostPlatform, - remoteDir: string, - command: string -): string { - if (!isWindowsRemoteHost(host)) { - return `cd ${shellEscape(remoteDir)} && ${command}` - } - return powerShellCommand( - `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(remoteDir)}; ${command}` - ) -} - export function commandWithNodePath( host: RemoteHostPlatform, nodePath: string, diff --git a/src/main/ssh/ssh-remote-platform.test.ts b/src/main/ssh/ssh-remote-platform.test.ts index 69c922dc64f..0f1f5b3cb67 100644 --- a/src/main/ssh/ssh-remote-platform.test.ts +++ b/src/main/ssh/ssh-remote-platform.test.ts @@ -1,27 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { assertSafeRemotePathSegment, getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn() -})) - -const conn = {} as SshConnection - -function decodePowerShellCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' -} - -beforeEach(() => { - vi.clearAllMocks() -}) describe('joinRemotePath', () => { it('joins POSIX remote paths', () => { @@ -77,33 +59,3 @@ describe('assertSafeRemotePathSegment', () => { ) }) }) - -describe('detectRemoteHostPlatform', () => { - it('uses uname when the remote is POSIX', async () => { - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64') - - await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ - relayPlatform: 'darwin-arm64', - commandDialect: 'posix' - }) - }) - - it('falls back to PowerShell when uname is unavailable on Windows', async () => { - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not recognized')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64') - - await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ - relayPlatform: 'win32-x64', - commandDialect: 'powershell', - pathFlavor: 'windows' - }) - - expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') - const script = decodePowerShellCommand(vi.mocked(execCommand).mock.calls[1]?.[1] ?? '') - expect(script).toContain('$arch = $env:PROCESSOR_ARCHITECTURE') - expect(script).toContain('try { $runtimeArch =') - expect(script).toContain('catch {}') - expect(script).toContain('Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)') - }) -}) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index b477ad682ef..844f1dc847c 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -32,7 +32,6 @@ import { writeBufferViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' -import { spawnSystemSshPortForward } from './system-ssh-forward-process' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' import type { SystemSshResolvedConfig } from './ssh-control-socket' @@ -506,36 +505,6 @@ describe('spawnSystemSsh', () => { ) }) - it('spawns port forwards before the ssh destination terminator', () => { - spawnSystemSshPortForward(createTarget({ configHost: 'fdpass-host' }), 5173, '127.0.0.1', 3000) - - const args = spawnMock.mock.calls[0][1] as string[] - const terminatorIdx = args.indexOf('--') - const forwardFlagIdx = args.indexOf('-N') - const localForwardIdx = args.indexOf('-L') - const exitOnForwardFailureIdx = args.indexOf('ExitOnForwardFailure=yes') - const standaloneControlIdx = args.indexOf('-S') - - expect(terminatorIdx).toBeGreaterThan(-1) - expect(forwardFlagIdx).toBeGreaterThan(-1) - expect(localForwardIdx).toBeGreaterThan(-1) - expect(exitOnForwardFailureIdx).toBeGreaterThan(-1) - // Why: -N and -L must appear before -- or OpenSSH treats them as remote command args. - expect(forwardFlagIdx).toBeLessThan(terminatorIdx) - expect(localForwardIdx).toBeLessThan(terminatorIdx) - expect(args[exitOnForwardFailureIdx - 1]).toBe('-o') - expect(exitOnForwardFailureIdx).toBeLessThan(terminatorIdx) - expect(standaloneControlIdx).toBe(-1) - expectNoOrcaControlMasterArgs(args) - expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') - expect(args[terminatorIdx + 1]).toBe('fdpass-host') - expect(spawnMock).toHaveBeenCalledWith( - SYSTEM_SSH_PATH, - expect.any(Array), - expect.objectContaining({ stdio: ['ignore', 'ignore', 'pipe'] }) - ) - }) - it('can spawn a native remote command without the POSIX shell wrapper', () => { spawnSystemSshCommand(createTarget({ configHost: 'fdpass-host' }), 'echo hello', { wrapCommand: false @@ -880,14 +849,6 @@ describe('spawnSystemSsh', () => { vi.stubEnv('PATH', '') expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') }) - - it('returns a process wrapper with kill and onExit', () => { - const result = spawnSystemSsh(createTarget()) - - expect(result.pid).toBe(12345) - expect(typeof result.kill).toBe('function') - expect(typeof result.onExit).toBe('function') - }) }) describe('system SSH operation aborts', () => { diff --git a/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts new file mode 100644 index 00000000000..9191fb1666b --- /dev/null +++ b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' + +/** + * Turning Codex off in the per-agent hook settings removes Orca's Codex hook + * entry. Launch prep and session resume must read that same opt-out, or the + * next Codex launch writes the entry straight back. + */ +const SYSTEM_HOME = '/home/user/.codex' +const ACCOUNT_HOME = '/accounts/one/.codex' + +const mocks = vi.hoisted(() => { + const settings: Partial = {} + return { + settings, + prepareForCodexLaunchAsync: vi.fn(async (): Promise => null), + isHostSystemDefaultRealHomeSelected: vi.fn(() => false), + prepareRuntimeHomeForLaunch: vi.fn(async () => ({ state: 'ok' as const })), + installForLaunchPrep: vi.fn(async () => {}), + refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), + ensureRealHomeCodexHookState: vi.fn(async () => 'installed' as const), + prepareCodexSessionResume: vi.fn() + } +}) + +vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) +vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: vi.fn(async () => {}) })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + prepareRuntimeHomeForLaunch: mocks.prepareRuntimeHomeForLaunch, + installForLaunchPrep: mocks.installForLaunchPrep, + refreshRuntimeUserHooksForLaunchPrep: mocks.refreshRuntimeUserHooksForLaunchPrep + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState +})) +// Why: the real predicate, without loading every agent's hook service. +vi.mock( + '../agent-hooks/managed-agent-hook-controls', + async () => await import('../../shared/agent-status-hooks-setting') +) +vi.mock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu' })) +vi.mock('../codex/codex-home-paths', () => ({ + getSystemCodexHomePath: () => SYSTEM_HOME, + getOrcaManagedCodexHomePath: () => '/managed/.codex' +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: mocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: vi.fn(async () => ({ useRealCodexHome: false })) +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + prepareForCodexLaunchAsync: mocks.prepareForCodexLaunchAsync, + isHostSystemDefaultRealHomeSelected: mocks.isHostSystemDefaultRealHomeSelected, + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: () => null + }, + store: { getSettings: () => mocks.settings } + } +})) + +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +const HOOK_SETTINGS: readonly { + name: string + settings: Partial + codexHooksOn: boolean +}[] = [ + { + name: 'Codex turned off per agent', + settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] }, + codexHooksOn: false + }, + { + name: 'every agent on', + settings: { agentStatusHooksEnabled: true, disabledTuiAgents: [] }, + codexHooksOn: true + }, + { + name: 'another agent turned off', + settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['claude'] }, + codexHooksOn: true + }, + { + name: 'the global switch off', + settings: { agentStatusHooksEnabled: false, disabledTuiAgents: [] }, + codexHooksOn: false + } +] + +function resumeFrom(homePath: string): Promise { + mocks.prepareCodexSessionResume.mockImplementation( + async (args: { + resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise + }) => { + const codexHomePath = await args.resolveVerifiedResumeHome({ + homePath, + transcriptPath: `${homePath}/sessions/abc.jsonl` + }) + return { outcome: 'resume' as const, codexHomePath, sessionId: 'abc' } + } + ) + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' } + }) +} + +describe('Codex launch prep honours the per-agent hook opt-out', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.isHostSystemDefaultRealHomeSelected.mockReturnValue(false) + mocks.prepareForCodexLaunchAsync.mockResolvedValue(null) + }) + + it.each(HOOK_SETTINGS)( + 'real ~/.codex launch with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + mocks.isHostSystemDefaultRealHomeSelected.mockReturnValue(true) + + await expect(prepareCodexRuntimeHomeForLaunch()).resolves.toBeNull() + + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( + expect.objectContaining({ hooksEnabled: codexHooksOn }) + ) + expect(mocks.prepareRuntimeHomeForLaunch).not.toHaveBeenCalled() + } + ) + + it.each(HOOK_SETTINGS)( + 'managed account home launch with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + mocks.prepareForCodexLaunchAsync.mockResolvedValue(ACCOUNT_HOME) + + await expect(prepareCodexRuntimeHomeForLaunch()).resolves.toBe(ACCOUNT_HOME) + + expect(mocks.ensureRealHomeCodexHookState).not.toHaveBeenCalled() + expect(mocks.prepareRuntimeHomeForLaunch).toHaveBeenCalledWith( + ACCOUNT_HOME, + undefined, + codexHooksOn + ) + } + ) + + it.each(HOOK_SETTINGS)( + 'resume into the real ~/.codex with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + + await resumeFrom(SYSTEM_HOME) + + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( + expect.objectContaining({ hooksEnabled: codexHooksOn }) + ) + expect(mocks.installForLaunchPrep).not.toHaveBeenCalled() + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() + } + ) + + it.each(HOOK_SETTINGS)( + 'resume into a managed account home with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + + await resumeFrom(ACCOUNT_HOME) + + expect(mocks.ensureRealHomeCodexHookState).not.toHaveBeenCalled() + if (codexHooksOn) { + expect(mocks.installForLaunchPrep).toHaveBeenCalledWith(ACCOUNT_HOME) + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() + } else { + expect(mocks.installForLaunchPrep).not.toHaveBeenCalled() + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).toHaveBeenCalledWith(ACCOUNT_HOME) + } + } + ) +}) diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index 3b4e7fcaec0..06e61c18836 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -1,11 +1,9 @@ import { app } from 'electron' import type { CodexHomeLaunchContext } from '../ipc/pty' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { markCodexProjectTrusted } from '../agent-trust-presets' -import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' import { codexHookService } from '../codex/hook-service' import { getDefaultWslDistro } from '../wsl' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' import { mainProcessState as state } from './main-process-state' @@ -18,24 +16,6 @@ export async function prepareCodexRuntimeHomeForLaunch( if (!runtimeHome) { throw new Error('Codex runtime home service is not initialized') } - if ( - target?.runtime !== 'wsl' && - launchContext?.launchAgent === 'codex' && - launchContext.workspacePath - ) { - try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. Bounded so a wedged config lane cannot hang the spawn that waits on this prep. - await awaitAgentTrustWriteWithinDeadline( - markCodexProjectTrusted(launchContext.workspacePath), - { - preset: 'codex', - workspacePath: launchContext.workspacePath - } - ) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) - } - } const ensureRealHomeHooksIfSelected = async (): Promise => { if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { return false @@ -45,7 +25,7 @@ export async function prepareCodexRuntimeHomeForLaunch( // the pane spawns. An incapable grant flips the lane gate so the launch // below falls back to the managed home instead of a status-blind pane. await ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()), + hooksEnabled: isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex'), userDataPath: app.getPath('userData') }) return true @@ -77,7 +57,7 @@ export async function prepareCodexRuntimeHomeForLaunch( target?.runtime === 'wsl' ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } : target - const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings()) + const hooksEnabled = isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex') try { // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. const status = await codexHookService.prepareRuntimeHomeForLaunch( diff --git a/src/main/startup/codex-launch-trust-write-deadline.test.ts b/src/main/startup/codex-launch-trust-write-deadline.test.ts deleted file mode 100644 index c7036d4f6a7..00000000000 --- a/src/main/startup/codex-launch-trust-write-deadline.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' - -/** - * Why this file exists: both Codex launch-prep entry points await the trust - * write, and the PTY spawn awaits them, so an unbounded write turns a stuck - * per-`config.toml` lane into a launch that never starts. These cases pin the - * bound and the fail-closed consequence: giving up writes nothing, so the - * workspace stays untrusted and Codex raises its own trust menu. - */ -const mocks = vi.hoisted(() => ({ - markCodexProjectTrusted: vi.fn(), - prepareForCodexLaunchAsync: vi.fn(), - isHostSystemDefaultRealHomeSelected: vi.fn(() => false), - isHostSystemDefaultRealHome: vi.fn(() => false), - getHostCodexHomePathsForSessionDiscovery: vi.fn((): string[] => []), - resolveSelectedHostAccountCodexHomePathForResume: vi.fn(() => null), - prepareRuntimeHomeForLaunch: vi.fn(async () => ({ state: 'ok' as const })), - installForLaunchPrep: vi.fn(async () => {}), - refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), - ensureRealHomeCodexHookState: vi.fn(async () => {}), - prepareCodexSessionResume: vi.fn(), - prepareLegacySharedCodexSessionResume: vi.fn(async () => ({ useRealCodexHome: false })) -})) - -vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) -vi.mock('../agent-trust-presets', () => ({ - markCodexProjectTrusted: mocks.markCodexProjectTrusted -})) -vi.mock('../codex/hook-service', () => ({ - codexHookService: { - prepareRuntimeHomeForLaunch: mocks.prepareRuntimeHomeForLaunch, - installForLaunchPrep: mocks.installForLaunchPrep, - refreshRuntimeUserHooksForLaunchPrep: mocks.refreshRuntimeUserHooksForLaunchPrep - } -})) -vi.mock('../codex/codex-real-home-hook-install', () => ({ - ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState -})) -vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ - isAgentStatusHooksEnabled: () => false -})) -vi.mock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu' })) -vi.mock('../codex/codex-home-paths', () => ({ - getSystemCodexHomePath: () => '/home/user/.codex', - getOrcaManagedCodexHomePath: () => '/managed/.codex' -})) -vi.mock('../codex/codex-session-resume-preparation', () => ({ - prepareCodexSessionResume: mocks.prepareCodexSessionResume -})) -vi.mock('../codex/codex-legacy-session-resume', () => ({ - prepareLegacySharedCodexSessionResume: mocks.prepareLegacySharedCodexSessionResume -})) -vi.mock('./main-process-state', () => ({ - mainProcessState: { - codexRuntimeHome: { - prepareForCodexLaunchAsync: mocks.prepareForCodexLaunchAsync, - isHostSystemDefaultRealHomeSelected: mocks.isHostSystemDefaultRealHomeSelected, - isHostSystemDefaultRealHome: mocks.isHostSystemDefaultRealHome, - getHostCodexHomePathsForSessionDiscovery: mocks.getHostCodexHomePathsForSessionDiscovery, - resolveSelectedHostAccountCodexHomePathForResume: - mocks.resolveSelectedHostAccountCodexHomePathForResume - }, - store: { getSettings: () => ({}) } - } -})) - -import { AGENT_TRUST_WRITE_DEADLINE_MS } from '../agent-trust-write-deadline' -import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' -import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' - -const WORKSPACE = '/workspace/app' -const RESUME_HOME = '/accounts/one/.codex' - -function neverSettlingWrite(): { promise: Promise; release: () => void } { - let release!: () => void - const promise = new Promise((resolve) => { - release = resolve - }) - return { promise, release } -} - -describe('Codex launch-prep trust writes', () => { - let warn: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - mocks.isHostSystemDefaultRealHomeSelected.mockReturnValue(false) - mocks.getHostCodexHomePathsForSessionDiscovery.mockReturnValue([]) - mocks.resolveSelectedHostAccountCodexHomePathForResume.mockReturnValue(null) - mocks.prepareForCodexLaunchAsync.mockResolvedValue(RESUME_HOME) - mocks.prepareRuntimeHomeForLaunch.mockResolvedValue({ state: 'ok' as const }) - mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: false }) - mocks.prepareCodexSessionResume.mockImplementation( - async (args: { - resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise - }) => { - const codexHomePath = await args.resolveVerifiedResumeHome({ - homePath: RESUME_HOME, - transcriptPath: `${RESUME_HOME}/sessions/abc.jsonl` - }) - return { outcome: 'resume' as const, codexHomePath, sessionId: 'abc' } - } - ) - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warn.mockRestore() - vi.useRealTimers() - }) - - it('bounds a never-settling write in quick-launch prep and still returns the launch home', async () => { - vi.useFakeTimers() - const write = neverSettlingWrite() - mocks.markCodexProjectTrusted.mockReturnValue(write.promise) - let settled = false - const prep = prepareCodexRuntimeHomeForLaunch( - undefined, - {}, - { - launchAgent: 'codex', - workspacePath: WORKSPACE - } - ).then((home) => { - settled = true - return home - }) - - await vi.advanceTimersByTimeAsync(AGENT_TRUST_WRITE_DEADLINE_MS - 1) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(2) - await expect(prep).resolves.toBe(RESUME_HOME) - expect(settled).toBe(true) - expect(String(warn.mock.calls[0]?.[0])).toContain('did not settle') - expect(vi.getTimerCount()).toBe(0) - // Fails closed: one abandoned write, no retry and no second trust path, so - // nothing in this prep can report the workspace as trusted. - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledTimes(1) - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledWith(WORKSPACE) - write.release() - }) - - it('keeps the quick-launch trust write ahead of the home the spawn waits on', async () => { - mocks.markCodexProjectTrusted.mockResolvedValue(undefined) - await prepareCodexRuntimeHomeForLaunch( - undefined, - {}, - { - launchAgent: 'codex', - workspacePath: WORKSPACE - } - ) - expect(mocks.markCodexProjectTrusted.mock.invocationCallOrder[0]).toBeLessThan( - mocks.prepareForCodexLaunchAsync.mock.invocationCallOrder[0] ?? Infinity - ) - }) - - it('bounds a never-settling write in resume prep and still resolves the resume home', async () => { - vi.useFakeTimers() - const write = neverSettlingWrite() - mocks.markCodexProjectTrusted.mockReturnValue(write.promise) - let settled = false - const prep = prepareCodexSessionResumeForLaunch({ - providerSession: { key: 'session_id', id: 'abc' }, - target: { runtime: 'host' }, - workspacePath: WORKSPACE - }).then((preparation) => { - settled = true - return preparation - }) - - await vi.advanceTimersByTimeAsync(AGENT_TRUST_WRITE_DEADLINE_MS - 1) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(2) - const preparation = await prep - expect(preparation?.outcome).toBe('resume') - expect(String(warn.mock.calls[0]?.[0])).toContain('did not settle') - expect(vi.getTimerCount()).toBe(0) - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledTimes(1) - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledWith(WORKSPACE) - write.release() - }) - - it('keeps the resume trust write ahead of the hook repair that precedes the spawn', async () => { - mocks.markCodexProjectTrusted.mockResolvedValue(undefined) - await prepareCodexSessionResumeForLaunch({ - providerSession: { key: 'session_id', id: 'abc' }, - target: { runtime: 'host' }, - workspacePath: WORKSPACE - }) - expect(mocks.markCodexProjectTrusted.mock.invocationCallOrder[0]).toBeLessThan( - mocks.refreshRuntimeUserHooksForLaunchPrep.mock.invocationCallOrder[0] ?? Infinity - ) - }) - - it('contains a rejected resume trust write without trusting the workspace elsewhere', async () => { - mocks.markCodexProjectTrusted.mockRejectedValue(new Error('mock EACCES')) - const preparation = await prepareCodexSessionResumeForLaunch({ - providerSession: { key: 'session_id', id: 'abc' }, - target: { runtime: 'host' }, - workspacePath: WORKSPACE - }) - expect(preparation?.outcome).toBe('resume') - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/startup/codex-session-resume-daemon-guard.test.ts b/src/main/startup/codex-session-resume-daemon-guard.test.ts index aecaadcf8b6..4ac5b2fdabb 100644 --- a/src/main/startup/codex-session-resume-daemon-guard.test.ts +++ b/src/main/startup/codex-session-resume-daemon-guard.test.ts @@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) -vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: async () => {} })) vi.mock('../codex/hook-service', () => ({ codexHookService: { installForLaunchPrep: mocks.installForLaunchPrep, @@ -31,7 +30,7 @@ vi.mock('../codex/codex-real-home-hook-install', () => ({ ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState })) vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ - isAgentStatusHooksEnabled: () => mocks.hooksEnabled + isAgentStatusHooksEnabledForAgent: () => mocks.hooksEnabled })) vi.mock('../codex/codex-home-paths', async (importOriginal) => ({ ...(await importOriginal()), @@ -98,8 +97,7 @@ describe('Codex session resume daemon socket guard', () => { function resume(): ReturnType { return prepareCodexSessionResumeForLaunch({ providerSession: { key: 'session_id', id: 'abc' }, - target: { runtime: 'host' }, - workspacePath: join(root, 'workspace') + target: { runtime: 'host' } }) } diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index 1ce7f8c3a15..a827ce2849f 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -8,9 +8,7 @@ import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/h import { codexHookService } from '../codex/hook-service' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { markCodexProjectTrusted } from '../agent-trust-presets' -import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { mainProcessState as state } from './main-process-state' @@ -19,7 +17,6 @@ export async function prepareCodexSessionResumeForLaunch(args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }): Promise { const runtimeHome = state.codexRuntimeHome const store = state.store @@ -37,7 +34,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { // readable alias wins. A throw here refuses the whole resume instead // (#STA-4422). const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() - // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is + // Why: a `fresh` outcome must skip migration and hook repair entirely — there is // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ sessionId: args.providerSession.id, @@ -82,21 +79,10 @@ export async function prepareCodexSessionResumeForLaunch(args: { ) } const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - if (args.workspacePath) { - try { - // Why: the PTY spawn waits on the home this resolver returns, so the write stays ahead of the agent's trust menu — bounded so a wedged lane cannot hang the resume. - await awaitAgentTrustWriteWithinDeadline(markCodexProjectTrusted(args.workspacePath), { - preset: 'codex', - workspacePath: args.workspacePath - }) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) - } - } const isSystemHome = normalizeRuntimePathForComparison(resumeHome) === normalizeRuntimePathForComparison(systemHomePath) - const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + const hooksEnabled = isAgentStatusHooksEnabledForAgent(store.getSettings(), 'codex') try { if (isSystemHome) { await ensureRealHomeCodexHookState({ diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index fd4379354c4..70747c926f4 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -942,39 +942,6 @@ describe('safe graphics mode startup switches', () => { ) }) - // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs - // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything - // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { - const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') - expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = mainSource.indexOf('\n }', gateStart) - expect(gateEnd).toBeGreaterThan(gateStart) - - expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1) - expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling') - }) - - // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready - // top-level block and never move into the whenReady callback, where appendSwitch is a silent - // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in preflight', () => { - const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') - const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const preflightEnd = mainSource.indexOf('\n return true') - const readyStart = entrySource.indexOf('void app.whenReady()') - const preflightCall = entrySource.indexOf('runMainProcessPreflight({') - expect(preflightEnd).toBeGreaterThan(0) - expect(readyStart).toBeGreaterThan(0) - expect(preflightCall).toBeGreaterThanOrEqual(0) - expect(preflightCall).toBeLessThan(readyStart) - - const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') - expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(preflightEnd) - }) - // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out // must never become reachable only through the GPU-feature path again. it('does not couple the throttling opt-out to GPU feature setup', async () => { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 40d20d73b03..ce8148ea5d2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -3,399 +3,6 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('startup ordering', () => { - it('passes the startup barrier into PTY handlers without blocking window creation', () => { - const attachSource = readFileSync( - join(process.cwd(), 'src/main/window/attach-main-window-services.ts'), - 'utf8' - ) - const startupSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), - 'utf8' - ) - const coreSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), - 'utf8' - ) - const runtimeLaunchSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), - 'utf8' - ) - const attachStart = attachSource.indexOf('export function attachMainWindowServices(') - const attachEnd = attachSource.indexOf(' registerSshHandlers(', attachStart) - const attachBlock = attachSource.slice(attachStart, attachEnd) - // Why: anchor on the destructure head only — the settled-result variable's name is not the - // contract, and pinning it turns a rename into a cryptic `expected -1` failure here. - const desktopStart = runtimeLaunchSource.indexOf('async function launchDesktopMode(') - // Why: anchor on code, not a comment — the previous comment anchor was silently reworded, so - // this was -1 and sliced to EOF, letting the assertions below pass against never-run code. - const desktopEnd = runtimeLaunchSource.indexOf( - '\nexport async function initializeMainProcessRuntimeLaunch', - desktopStart - ) - const desktopStartup = runtimeLaunchSource.slice(desktopStart, desktopEnd) - - // Why: bound every anchor, not just the desktop pair — an unresolved one slices to EOF. - expect(attachStart).toBeGreaterThanOrEqual(0) - expect(attachEnd).toBeGreaterThan(attachStart) - expect(desktopStart).toBeGreaterThanOrEqual(0) - expect(desktopEnd).toBeGreaterThan(desktopStart) - - expect(coreSource).toContain('awaitLocalPtyStartup: () => state.localPtyStartupReady') - expect(coreSource).toContain( - 'awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady' - ) - expect(attachBlock).toContain('awaitLocalPtyStartup: options?.awaitLocalPtyStartup') - expect(attachBlock).toContain( - 'awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup' - ) - expect(startupSource).toContain( - 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' - ) - expect(startupSource).toContain( - 'localPtyStartupReady = services.then((value) => value.localPtyReady)' - ) - - const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') - const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') - const legacyRpcStartIndex = desktopStartup.indexOf('runtimeRpc.start()') - - expect(windowIndex).toBeGreaterThanOrEqual(0) - expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) - expect(desktopStartup).toMatch( - /shellPathReady\s*\.then\(\(\) => (?:desktopRuntimeRpc|runtimeRpc)\.start\(\)\)/ - ) - expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') - // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. - // It chains off the i18n barrier (published before this phase starts) so the translated strings - // it reads are loaded, which is a wait on i18n only, never on the dialog itself. - expect(desktopStartup).toMatch( - /void state\.mainProcessI18nReady\.then\(\(\) =>\s*showRuntimeRpcStartupFailureDialog\(\s*win,/ - ) - expect(desktopStartup).not.toMatch(/await[^\n]*showRuntimeRpcStartupFailureDialog\(/) - // Why (#11025): a bare console.error here is exactly what left the CLI dead but the app healthy. - expect(desktopStartup).not.toContain( - "console.error('[runtime] Failed to start local RPC transport:'" - ) - }) - - it('resolves the browser hosting identity with nothing awaited before it', () => { - const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const foundationSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), - 'utf8' - ) - const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') - const initReadyIndex = entrySource.indexOf('initializeMainProcessReady({') - const profileIndex = foundationSource.indexOf('const profile = ensureActiveOrcaProfile()') - const initIndex = foundationSource.indexOf( - 'initializeBrowserClientHostId(profile.profileDirectory)' - ) - - expect(readyIndex).toBeGreaterThanOrEqual(0) - expect(initReadyIndex).toBeGreaterThan(readyIndex) - expect(profileIndex).toBeGreaterThanOrEqual(0) - expect(initIndex).toBeGreaterThan(profileIndex) - // Why nothing may be awaited first: the identity is stamped into the renderer's argv when the - // window is created, and a suspension here lets a window be created against a process-local - // stand-in that the durable id then contradicts. The constraint is positional, so only a source - // census can hold it — no behavioural test distinguishes "resolved" from "resolved in time". - expect(foundationSource.slice(profileIndex, initIndex)).not.toMatch(/\bawait\b/) - // Why the count: a second call site would leave the ordering claim above ambiguous. - expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) - }) - - it('fails closed with offline recovery guidance when profile state is unreadable', () => { - const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - - expect(entrySource).toContain('formatProfileStateStartupFailure') - expect(entrySource).toContain('formatProfileStateStartupFailure(error) ??') - expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') - expect(entrySource).toContain('!state.isServeMode && !isBackgroundLaunch()') - expect(entrySource).toContain( - "console.warn('[profile-state] Recovery dialog failed; exiting safely:'" - ) - expect(entrySource).toContain('app.exit(1)') - }) - - it('initializes telemetry before publishing profile-state authority selection', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-observers.ts'), - 'utf8' - ) - const telemetryInit = source.indexOf('initTelemetry(store)') - const authoritySelection = source.indexOf("track('profile_state_authority_selected'") - expect(telemetryInit).toBeGreaterThanOrEqual(0) - expect(authoritySelection).toBeGreaterThan(telemetryInit) - }) - - it('requires daemon authority before restored-subagent liveness runs', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), - 'utf8' - ) - const sweepStart = source.indexOf( - 'export async function reapRestoredSubagentsWithoutLiveAgent()' - ) - const sweepEnd = source.indexOf( - 'export function startTerminalRuntimeStartupServices()', - sweepStart - ) - const sweep = source.slice(sweepStart, sweepEnd) - - expect(sweepStart).toBeGreaterThanOrEqual(0) - expect(sweepEnd).toBeGreaterThan(sweepStart) - expect(sweep).toContain('const provider = getDaemonProvider()') - expect(sweep).toContain('if (!provider) {') - expect(sweep).toContain('provider.probePtyLiveness(ptyId)') - }) - - it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { - const barrierSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), - 'utf8' - ) - const foundationSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), - 'utf8' - ) - const runtimeSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), - 'utf8' - ) - const barrierStart = barrierSource.indexOf( - "ipcMain.handle('app:awaitFirstWindowStartupServices'" - ) - const barrierEnd = barrierSource.indexOf("'app:startupDiagnostic'", barrierStart) - const barrier = barrierSource.slice(barrierStart, barrierEnd) - const reconciliationStart = foundationSource.indexOf( - 'state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' - ) - const serveStart = runtimeSource.indexOf('async function launchServeMode(') - const serveEnd = runtimeSource.indexOf('\nasync function launchDesktopMode', serveStart) - const serveStartup = runtimeSource.slice(serveStart, serveEnd) - const desktopStart = runtimeSource.indexOf( - " if (process.platform === 'win32' && app.isPackaged && !serveOptions)" - ) - const desktopEnd = runtimeSource.indexOf(" app.on('activate'", desktopStart) - const desktopStartup = runtimeSource.slice(desktopStart, desktopEnd) - - expect(barrierStart).toBeGreaterThanOrEqual(0) - expect(barrierEnd).toBeGreaterThan(barrierStart) - expect(reconciliationStart).toBeGreaterThanOrEqual(0) - expect(serveStart).toBeGreaterThanOrEqual(0) - expect(serveEnd).toBeGreaterThan(serveStart) - expect(desktopStart).toBeGreaterThanOrEqual(0) - expect(desktopEnd).toBeGreaterThan(desktopStart) - expect(serveStartup).toContain('await state.managedWslCliStartupBarrierReady') - expect(serveStartup).not.toContain('await state.managedWslCliReconciliationReady') - expect(serveStartup.indexOf('await state.managedWslCliStartupBarrierReady')).toBeLessThan( - serveStartup.indexOf('await runtimeRpc.start()') - ) - expect(desktopStartup).not.toContain('await state.managedWslCliReconciliationReady') - expect(desktopStartup).toContain( - "process.platform === 'win32' && app.isPackaged && !serveOptions" - ) - expect(desktopStartup).toContain( - 'openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true })' - ) - expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices') - expect(desktopStartup).toContain('shellPathReady,') - expect(desktopStartup).toContain('startServices: startTerminalRuntimeStartupServices') - expect(barrier).toContain('managedWslCliStartupBarrierReady') - expect(barrier).not.toContain('managedWslCliReconciliationReady') - expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") - expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({') - expect(barrier).toContain('localPtyProviderStartupReady,') - expect(barrier).toContain('await state.runtime?.refreshRestoredOrchestrationAuthority()') - expect(barrier).toContain( - 'return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' - ) - }) - - it('keeps the git-environment barrier off the PTY startup services', () => { - const barrierSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), - 'utf8' - ) - const launchSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), - 'utf8' - ) - const gitBarrierStart = barrierSource.indexOf( - "ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'" - ) - const gitBarrierEnd = barrierSource.indexOf( - "'app:prepareTerminalStartupRestoration'", - gitBarrierStart - ) - expect(gitBarrierStart).toBeGreaterThanOrEqual(0) - expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart) - const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd) - // The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a - // hook-server bind here puts terminal startup back in front of worktree hydration. - expect(gitBarrier).toContain('state.shellPathReady') - expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady') - expect(gitBarrier).not.toContain('firstWindowStartupServicesReady') - // The published promise must be the same one the terminal startup services wait on. - expect(launchSource).toContain('state.shellPathReady = shellPathReady') - expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan( - launchSource.indexOf('await launchDesktopMode(') - ) - // Terminal restoration itself must still fence on the first-window services. - const restorationStart = barrierSource.indexOf( - "ipcMain.handle('app:prepareTerminalStartupRestoration'" - ) - const restorationEnd = barrierSource.indexOf( - "'app:recoverLegacyWorkerTerminalsForRendererStartup'", - restorationStart - ) - expect(barrierSource.slice(restorationStart, restorationEnd)).toContain( - 'state.firstWindowStartupServicesReady' - ) - }) - - it('reconciles retained Codex homes after authoritative daemon inventory', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), - 'utf8' - ) - const startupStart = source.indexOf('export function startTerminalRuntimeStartupServices()') - expect(startupStart).toBeGreaterThanOrEqual(0) - const startup = source.slice(startupStart) - const daemonInitIndex = startup.indexOf('await initDaemonPtyProvider(signal') - const retainedPaneGateIndex = startup.indexOf( - 'hasRecordedManagedHostCodexPane()', - daemonInitIndex - ) - const inventoryIndex = startup.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) - const reconciliation = 'state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' - const reconciliationIndex = startup.indexOf(reconciliation, inventoryIndex) - const hookReconciliationIndex = startup.indexOf( - 'reconcileRetainedCodexHookHomes({', - inventoryIndex - ) - - expect(daemonInitIndex).toBeGreaterThanOrEqual(0) - expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) - expect(inventoryIndex).toBeGreaterThan(daemonInitIndex) - expect(inventoryIndex).toBeGreaterThan(retainedPaneGateIndex) - expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) - expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) - expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) - // The call is intentionally kept after the authoritative inventory; anchoring on the state - // receiver avoids matching any prose that mentions the same operation. - expect(startup).toContain(reconciliation) - }) - - it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { - const serveSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-serve.ts'), - 'utf8' - ) - const runtimeSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), - 'utf8' - ) - const foundationSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), - 'utf8' - ) - - // Why: the barrier fails open, so the serve-ready payload must carry the - // reconciliation state and the bounded wait must be traceable via a milestone. - const readyStart = serveSource.indexOf('await state.serveReadinessPublisher.publish(') - const readyEnd = serveSource.indexOf('pairing: pairing.available', readyStart) - const readyPayload = serveSource.slice(readyStart, readyEnd) - - // Why: unbounded, a renamed pairing key slices to EOF and the status only has to survive - // somewhere later in the file — not in the serve-ready payload this test is about. - expect(readyStart).toBeGreaterThanOrEqual(0) - expect(readyEnd).toBeGreaterThan(readyStart) - expect(readyPayload).toContain( - 'managedWslCliReconciliation: state.managedWslCliReconciliationStatus' - ) - - expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'pending'") - expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'settled'") - expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'failed'") - expect(runtimeSource).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") - }) - - it('notifies the serve supervisor only after publishing readiness', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-serve.ts'), - 'utf8' - ) - const readyStart = source.indexOf('await state.serveReadinessPublisher.publish(') - const supervisorReady = source.indexOf('notifyServeSupervisorReady(', readyStart) - - expect(readyStart).toBeGreaterThanOrEqual(0) - expect(supervisorReady).toBeGreaterThan(readyStart) - }) - - it('does not run the rate-limit quota fetch before the first window can show results', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), - 'utf8' - ) - const attachIndex = source.indexOf('rateLimits.attach(window)') - const startIndex = source.indexOf('rateLimits.start({ fetchImmediately: false })') - - expect(attachIndex).toBeGreaterThanOrEqual(0) - expect(startIndex).toBeGreaterThan(attachIndex) - }) - - it('wires bounded teardown state to reporting but not recovery or close behavior', () => { - const lifecycleSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-lifecycle-flags.ts'), - 'utf8' - ) - const windowSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-controller.ts'), - 'utf8' - ) - - expect(lifecycleSource).toContain('export function getExpectedTeardownScope(') - expect(lifecycleSource).toContain('resolveExpectedTeardownScope({') - expect(lifecycleSource).toContain('includeSystemSessionEnd') - expect(windowSource).toContain('const window = createMainWindow(store, {') - expect(windowSource).toContain('getIsQuitting: () => state.isQuitting') - expect(windowSource).toContain( - 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' - ) - expect(lifecycleSource).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') - }) - - it('attaches renderer services before starting the TCC prompt watcher', () => { - const coreSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), - 'utf8' - ) - const windowSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-controller.ts'), - 'utf8' - ) - const quitSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-quit.ts'), - 'utf8' - ) - const attachIndex = coreSource.indexOf('attachMainWindowServices(') - const tccNoticeIndex = coreSource.indexOf('initTccPromptNotice(window', attachIndex) - const quitAbortStart = windowSource.indexOf('onQuitAborted:') - const quitAbortEnd = windowSource.indexOf('onRendererProcessGone:', quitAbortStart) - - expect(attachIndex).toBeGreaterThanOrEqual(0) - expect(tccNoticeIndex).toBeGreaterThan(attachIndex) - expect(coreSource.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( - 'deferWatchUntilReadyToShow: true' - ) - expect(windowSource.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') - expect(quitSource).toContain("process.once('exit', stopTccPromptNotice)") - const willQuitStart = quitSource.indexOf("app.on('will-quit'") - expect(quitSource.slice(willQuitStart)).toContain('stopTccPromptNotice()') - expect(quitSource).not.toContain('stopTccPromptNoticeForQuit') - }) - it('keeps the power bridge through vetoable before-quit and disposes after commit', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-quit.ts'), @@ -417,23 +24,6 @@ describe('startup ordering', () => { expect(disposeIndex).toBeGreaterThan(commitIndex) }) - it('joins structured agent sessions to the committed quit barrier', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-quit.ts'), - 'utf8' - ) - const willQuitStart = source.indexOf("app.on('will-quit'") - const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) - const willQuit = source.slice(willQuitStart, willQuitEnd) - - expect(willQuit).toContain( - 'const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()' - ) - expect(willQuit).toContain( - "{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown }" - ) - }) - it('joins agent-browser cleanup before the committed quit exits', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-quit.ts'), @@ -473,77 +63,4 @@ describe('startup ordering', () => { expect(signalHandlers).toBeGreaterThan(serveStart) expect(signalHandlers).toBeLessThan(serveReady) }) - - it('starts the automation scheduler before headless serve reports ready', () => { - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), - 'utf8' - ) - const windowSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), - 'utf8' - ) - const serveStart = source.indexOf('async function launchServeMode(') - const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveEnd = source.indexOf('\nasync function launchDesktopMode', serveStart) - const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart) - const automationStart = source.indexOf('state.automations?.start()', serveStart) - const desktopSetWebContents = windowSource.indexOf( - 'automations.setWebContents(window.webContents)' - ) - const desktopAutomationStart = windowSource.indexOf( - 'automations.start()', - desktopSetWebContents + 1 - ) - - expect(serveStart).toBeGreaterThanOrEqual(0) - expect(serveReady).toBeGreaterThan(serveStart) - expect(serveEnd).toBeGreaterThan(serveReady) - expect(runtimeRpcStart).toBeGreaterThan(serveStart) - expect(automationStart).toBeGreaterThan(runtimeRpcStart) - expect(automationStart).toBeLessThan(serveReady) - expect(automationStart).toBeLessThan(serveEnd) - expect(desktopSetWebContents).toBeGreaterThanOrEqual(0) - expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents) - }) - - it('installs the serve supervisor disconnect quit after the app environment and data path', () => { - // Why (#16761): the call resolves the handoff path through getCanonicalUserDataPath(). At module - // scope that accessor throws by design, so every `orca serve` process on macOS died at startup - // before it could listen. serve-update-handoff.test.ts mocks the resolver, so only ordering - // catches this; serve-update-handoff.app-environment.test.ts pins the throw it depends on. - const source = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), - 'utf8' - ) - const install = 'installServeSupervisorDisconnectQuit(state.isServeMode)' - const appEnvironmentIndex = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') - const dataPathIndex = source.indexOf('initDataPath()') - const installIndex = source.indexOf(install) - // Why this anchor: preflight returns early when the lock is lost, so this gate is the split's - // equivalent of the old `if (hasSingleInstanceLock)` block head. - const lockGateIndex = source.indexOf('if (!hasLock) {') - - expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1) - expect(appEnvironmentIndex).toBeGreaterThanOrEqual(0) - expect(dataPathIndex).toBeGreaterThan(appEnvironmentIndex) - expect(installIndex).toBeGreaterThan(dataPathIndex) - expect(lockGateIndex).toBeGreaterThanOrEqual(0) - expect(installIndex).toBeGreaterThan(lockGateIndex) - - // Why also pin it synchronous: 'disconnect' cannot be delivered while preflight is still - // running, which is the whole reason deferring it is free. Parked behind an await — say - // inside app.whenReady() — the ordering above still holds but a parent that dies in the gap - // leaves the serve process orphaned on its port, which is the failure this handler prevents. - expect(source).toContain('export function runMainProcessPreflight(') - // Why only statements at block indentation: the span covers unrelated helper bodies, and an - // `await` inside one of those is not what this guards against — the risk is this call itself - // being parked behind one. - const blockStatements = source - .slice(lockGateIndex, installIndex) - .split('\n') - .filter((line) => /^ {2}\S/.test(line) && !line.trim().startsWith('//')) - .join('\n') - expect(blockStatements).not.toContain('await') - }) }) diff --git a/src/main/startup/dev-education-suppression.test.ts b/src/main/startup/dev-education-suppression.test.ts index 534c64b3aa2..5e628920e51 100644 --- a/src/main/startup/dev-education-suppression.test.ts +++ b/src/main/startup/dev-education-suppression.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { getDefaultUIState } from '../../shared/constants' import { getDefaultOnboardingState, - getDefaultUIState, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION -} from '../../shared/constants' +} from '../../shared/onboarding-defaults' import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours' import { FEATURE_INTERACTION_IDS } from '../../shared/feature-interactions' import { FEATURE_TIP_IDS } from '../../shared/feature-tips' diff --git a/src/main/startup/dev-education-suppression.ts b/src/main/startup/dev-education-suppression.ts index bea97e7bbc8..1f6d1afe188 100644 --- a/src/main/startup/dev-education-suppression.ts +++ b/src/main/startup/dev-education-suppression.ts @@ -1,4 +1,4 @@ -import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../shared/constants' +import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../shared/onboarding-defaults' import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours' import { FEATURE_INTERACTION_IDS, diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 7a677ed760e..42338910e2a 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -18,9 +18,9 @@ import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { agentHookServer } from '../agent-hooks/server' import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { shouldSuppressSystemCodexHomeHookSweep } from '../codex/codex-hook-legacy-cleanup' import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { browserManager } from '../browser/browser-manager' @@ -45,13 +45,13 @@ export function initializeMainProcessAccountServices(): void { state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - state.codexRuntimeHome !== null && - state.codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(state.store?.getSettings()) + // real-home installer just appended. Flag OFF, hooks off (all or Codex), or an + // incapable trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. + setSystemCodexHomeHookSweepSuppressed(() => + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: state.codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + settings: state.store?.getSettings() + }) ) state.codexSessionMigration = createCodexSessionMigrationScheduler({ isEligible: () => diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 100f1e21386..0a9afbd8079 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -35,7 +35,12 @@ import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instan import { enableRendererHeapHeadroom } from './renderer-heap-headroom' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' import { startEventLoopStallProbe } from './event-loop-stall-probe' -import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe' +import { + isMainThreadDiagnosticsEnabled, + recordSubprocessSpawn, + startMainThreadChurnProbe +} from '../diagnostics/main-thread-churn-probe' +import { setSpawnObserver } from '../../shared/child-process/spawn-observer' import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' import { createServeDesktopActivationGate } from './serve-desktop-activation' @@ -222,9 +227,14 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b // Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). // Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache // look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. + if (isMainThreadDiagnosticsEnabled()) { + // Why here too: the probe's own call sites only cover src/main/git, so without + // this every spawnProcess/runProcess child (rg, ps, pty helpers) is invisible. + setSpawnObserver(recordSubprocessSpawn) + } startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. - // Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). + // Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy. const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) if (bypass) { @@ -236,12 +246,16 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b logStartupDiagnostic('single-instance-lock-result', { acquired: hasLock, bypassed: bypass, - skippedForDev: skip + skippedForE2E: skip }) } if (!hasLock) { // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() + // In dev it is the line `pnpm dev` prints before exiting. + logSingleInstanceLockFailure({ + isDevDesktop: isDev && !state.isServeMode, + userDataPath: app.getPath('userData') + }) // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 162faf66b2f..648db87a3b6 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -17,7 +17,10 @@ import { } from '../codex/codex-pane-account-registry' import { reconcileRetainedCodexHookHomes } from '../codex/retained-codex-hook-state' import { codexHookService } from '../codex/hook-service' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../agent-hooks/managed-agent-hook-controls' import { agentHookServer } from '../agent-hooks/server' import { indexPersistedPaneKeyPtyIds, @@ -146,9 +149,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ if (hasRetainedManagedHostPane) { void reconcileRetainedCodexHookHomes({ hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, + hooksEnabled: isAgentStatusHooksEnabledForAgent(settings, 'codex'), runtimeHomePaths: state.codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) }).catch((error) => console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index ad4548db7bd..f104a3ec343 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -15,6 +15,7 @@ import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { stopCodexAccountSessionBridges } from '../codex/codex-account-session-bridge' import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' @@ -134,6 +135,7 @@ function installWillQuitHandler(): void { state.pluginMarketplaceInstaller = null const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + stopCodexAccountSessionBridges() // Why before the stop: teardown stamps each working session's resume marker with why the app // went away, and an update install is a restart the user never chose. setStructuredAgentSessionTeardownTrigger(updateQuitInProgress ? 'update' : 'quit') diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cffd9656f09..3d715d33cd7 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -132,11 +132,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { runtimeHome: state.codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) }), - prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { - launchAgent: 'codex', - workspacePath - }), + prepareCodexStructuredLaunch: ({ launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), // Why throw like prepare does: a null from an uninitialized service would // map to the system home and key a catalog read to the wrong account. resolveCodexStructuredLaunchHome: ({ launchEnv }) => { diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 5899b2fe6bd..38bb380d724 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt receivedAt, evidenceObservedAt, stateStartedAt, + turnStartedAt, launchToken, providerSession, providerSessionOnly, @@ -89,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt receivedAt, ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, + ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index c7fe55da626..da26a593d00 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -94,3 +94,11 @@ it('forwards retirement acknowledgement only on live status delivery', () => { expect(sent[0].event).toHaveProperty('authorityRestartId', 'retirement-id') expect(sent[1].event).not.toHaveProperty('authorityRestartId') }) + +// The live push picks fields one by one; the host's turn start must be one of them. +it("forwards the host's turn start, and nothing when the host stamped none", () => { + hooks.listener!(statusPayload({ turnStartedAt: 1 })) + hooks.listener!(statusPayload({})) + expect(sent[0].event).toHaveProperty('turnStartedAt', 1) + expect(sent[1].event).not.toHaveProperty('turnStartedAt') +}) diff --git a/src/main/startup/secure-dns-census.test.ts b/src/main/startup/secure-dns-census.test.ts index 6393ac3f3a4..72177fc04d4 100644 --- a/src/main/startup/secure-dns-census.test.ts +++ b/src/main/startup/secure-dns-census.test.ts @@ -33,14 +33,4 @@ describe('secure DNS census', () => { expect(offenders).toEqual([]) }) - - it('detects a DoH mode when one is present', () => { - // Why: the census reads real sources, so it passes vacuously today; prove the matcher on a known offender. - const offending = "app.configureHostResolver({ secureDnsMode: 'automatic' })" - const modes = [...offending.matchAll(/secureDnsMode\s*:\s*'([^']*)'/g)].map((match) => match[1]) - - expect(offending.includes('configureHostResolver')).toBe(true) - expect(modes).toEqual(['automatic']) - expect(modes.every((mode) => mode === 'off')).toBe(false) - }) }) diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index e023d7895f5..f1b5abfe0a3 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -9,7 +9,8 @@ import { shouldSkipSingleInstanceLock, SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, - SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE + SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, + singleInstanceLockFailureMessage } from './single-instance-lock' type Listener = (...args: unknown[]) => void @@ -117,18 +118,23 @@ describe('SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE', () => { }) describe('shouldSkipSingleInstanceLock', () => { - it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { - expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) - expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + const E2E = { ORCA_E2E_USER_DATA_DIR: '/tmp/orca-e2e-userdata-1' } + + it('takes the lock for dev desktop, packaged and serve launches alike', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(false) expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: true, env: E2E })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: E2E })).toBe(false) }) - it('lets isolated E2E exercise the production single-instance path', () => { + it('skips only an E2E launch on its own profile that did not ask for the lock', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: E2E })).toBe(true) expect( shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, - env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + env: { ...E2E, ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } }) ).toBe(false) }) @@ -138,11 +144,27 @@ describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() - logSingleInstanceLockFailure(write) + logSingleInstanceLockFailure({ isDevDesktop: false, userDataPath: '/profile' }, write) expect(write).toHaveBeenCalledWith(2, `${SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE}\n`) expect(write.mock.calls[0]?.[1]).toContain('Electron/macOS single-instance lock failure') }) + + it('tells a second dev launch which profile is taken and how to run another copy', () => { + const write = vi.fn() + const userDataPath = '/Users/dev/Library/Application Support/orca-dev' + + logSingleInstanceLockFailure({ isDevDesktop: true, userDataPath }, write) + + expect(write).toHaveBeenCalledOnce() + const line = String(write.mock.calls[0]?.[1]) + expect(line).toBe(`${singleInstanceLockFailureMessage({ isDevDesktop: true, userDataPath })}\n`) + expect(line.trimEnd()).not.toContain('\n') + expect(line).toContain(`the profile at ${userDataPath}`) + expect(line).toContain('ORCA_DEV_USER_DATA_PATH=') + // A background or serve instance shows no window, so the line claims none. + expect(line).not.toMatch(/focus|window/i) + }) }) describe('shouldBypassSingleInstanceLock', () => { diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 69404b42e80..521b921408e 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -64,17 +64,37 @@ export function shouldBypassSingleInstanceLock(options: { ) } +// Why only the E2E harness: dev desktops lock like packaged ones, since two on one profile corrupt +// every store; each E2E launch has its own throwaway profile, and a spec opts in to test the lock. export function shouldSkipSingleInstanceLock(options: { env?: NodeJS.ProcessEnv isDev: boolean isServeMode: boolean }): boolean { const env = options.env ?? process.env - return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' + return ( + options.isDev && + !options.isServeMode && + Boolean(env.ORCA_E2E_USER_DATA_DIR) && + env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' + ) } -export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { - writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) +export function singleInstanceLockFailureMessage(options: { + isDevDesktop: boolean + userDataPath: string +}): string { + if (!options.isDevDesktop) { + return SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE + } + return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after passing it this launch's request. To run another dev copy at the same time, give it its own profile: ORCA_DEV_USER_DATA_PATH= pnpm dev` +} + +export function logSingleInstanceLockFailure( + options: { isDevDesktop: boolean; userDataPath: string }, + write?: StartupDiagnosticSink +): void { + writeStartupDiagnosticLine(singleInstanceLockFailureMessage(options), write) } export function logSingleInstanceLockBypass(write?: StartupDiagnosticSink): void { diff --git a/src/main/telemetry/onboarding-cohort-classifier.test.ts b/src/main/telemetry/onboarding-cohort-classifier.test.ts index e74b0deb8d5..c6ea7c848ed 100644 --- a/src/main/telemetry/onboarding-cohort-classifier.test.ts +++ b/src/main/telemetry/onboarding-cohort-classifier.test.ts @@ -4,7 +4,7 @@ // session. See docs/onboarding-telemetry-extensions.md §2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ONBOARDING_FINAL_STEP } from '../../shared/constants' +import { ONBOARDING_FINAL_STEP } from '../../shared/onboarding-defaults' import type { GlobalSettings } from '../../shared/global-settings-types' import type { OnboardingState } from '../../shared/onboarding-state-types' import type { Store } from '../persistence' diff --git a/src/main/telemetry/onboarding-cohort-classifier.ts b/src/main/telemetry/onboarding-cohort-classifier.ts index 8b840dc0e17..ec368fbbb74 100644 --- a/src/main/telemetry/onboarding-cohort-classifier.ts +++ b/src/main/telemetry/onboarding-cohort-classifier.ts @@ -2,7 +2,7 @@ // Known limitation: upgrade_backfill's "completed" shape (persistence.ts:362-369) is also written by live completion, so an existing live-completer flips fresh_install→upgrade_backfill; dashboards forward-fill cohort from _started. TODO: a wasBackfilledByMigration sentinel would disambiguate. // Never throws: returns { cohort: undefined } on any read/uninit error, which the schema's .optional() cohort still validates. Mirrors sibling getCohortAtEmit's never-crash contract. -import { ONBOARDING_FINAL_STEP } from '../../shared/constants' +import { ONBOARDING_FINAL_STEP } from '../../shared/onboarding-defaults' import type { OnboardingCohort } from '../../shared/telemetry-events' import type { Store } from '../persistence' diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index c5ce990f32c..4924b34ddcf 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -138,14 +138,6 @@ describe('terminal-history', () => { }) describe('resolveShellKind', () => { - it('detects zsh', () => { - expect(resolveShellKind('/bin/zsh')).toBe('zsh') - }) - - it('detects bash', () => { - expect(resolveShellKind('/bin/bash')).toBe('bash') - }) - it('detects versioned bash (bash-5.2)', () => { expect(resolveShellKind('/usr/local/bin/bash-5.2')).toBe('bash') }) @@ -158,24 +150,10 @@ describe('terminal-history', () => { expect(resolveShellKind('/nix/store/abc123/bin/zsh')).toBe('zsh') }) - it('detects fish', () => { - expect(resolveShellKind('/usr/bin/fish')).toBe('fish') - }) - it('detects pwsh', () => { expect(resolveShellKind('pwsh')).toBe('pwsh') expect(resolveShellKind('pwsh.exe')).toBe('pwsh') }) - - it('detects cmd.exe', () => { - expect(resolveShellKind('cmd.exe')).toBe('cmd') - }) - - it('returns unknown for unrecognized shells', () => { - expect(resolveShellKind('/bin/tcsh')).toBe('unknown') - expect(resolveShellKind('/bin/dash')).toBe('unknown') - expect(resolveShellKind('/bin/elvish')).toBe('unknown') - }) }) describe('hashWorktreeId', () => { diff --git a/src/main/updater-test-harness.leaked-timers.test.ts b/src/main/updater-test-harness.leaked-timers.test.ts deleted file mode 100644 index 6857852c0e2..00000000000 --- a/src/main/updater-test-harness.leaked-timers.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { setTimeout as sleep } from 'node:timers/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Mock } from 'vitest' -import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' - -const { autoUpdaterMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks } = - await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) - -vi.mock('electron', () => moduleFactories.electron()) -vi.mock('electron-updater', () => moduleFactories.electronUpdater()) -vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) -vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils()) -vi.mock('./ipc/pty', () => moduleFactories.ipcPty()) -vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) -vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) -vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) -vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) -vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) -vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) -vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) -vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) - -const SILENT_SETTLE_DELAY_MS = 1_000 -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 - -warmUpdaterModule() - -describe('updater test harness real-timer tracking', () => { - beforeEach(() => { - resetUpdaterMocks() - }) - - it('cancels a real timer armed before the reset', async () => { - let fired = false - setTimeout(() => { - fired = true - }, 20) - - resetUpdaterMocks() - - await sleep(150) - expect(fired).toBe(false) - }) - - it('still arms real timers after the reset', async () => { - let fired = false - setTimeout(() => { - fired = true - }, 10) - - await sleep(150) - expect(fired).toBe(true) - }) - - it('hands back the untouched Node timeout handle', () => { - const handle = setTimeout(() => {}, 60_000) - - // Why: production code calls unref() on these; the tracker must not swap in a plain id. - expect(typeof handle.unref).toBe('function') - handle.unref() - clearTimeout(handle) - }) -}) - -// Why: this pair reproduces the cross-test leak, so it depends on running in file order — the first -// test arms the timer and the second one is the later test it used to fire into. -describe('abandoned updater instance', () => { - let leakedStatusSend: Mock = vi.fn() - - beforeEach(() => { - resetUpdaterMocks() - }) - - it('leaves a silent-settle timer armed when its module instance is abandoned', async () => { - let resolveCheck: (value: unknown) => void = () => {} - autoUpdaterMock.checkForUpdates.mockImplementation(() => { - // Why: the checking status is what makes the silent settle publish 'not-available' later. - autoUpdaterMock.emit('checking-for-update') - return new Promise((resolve) => { - resolveCheck = resolve - }) - }) - leakedStatusSend = vi.fn() - - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: leakedStatusSend } } as never, { - getLastUpdateCheckAt: () => Date.now() - 25 * 60 * 60 * 1000 - }) - - await vi.waitFor(() => { - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) - }) - - // Why: resolving without a terminal event is what arms the 1s silent settle; do it here so the - // full delay is still ahead of us and the timer is guaranteed to be pending when this test ends. - resolveCheck(undefined) - await sleep(0) - - expect(leakedStatusSend).not.toHaveBeenCalledWith( - 'updater:status', - expect.objectContaining({ state: 'not-available' }) - ) - }) - - it('never reaches the shared spies once the next test owns the clock', async () => { - vi.useFakeTimers() - - // Why: real time past the settle delay — the abandoned instance would settle here and re-arm its - // 24h auto check on this test's fake clock at its epoch. - await sleep(SILENT_SETTLE_DELAY_MS + 300) - await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS) - - expect(leakedStatusSend).not.toHaveBeenCalledWith( - 'updater:status', - expect.objectContaining({ state: 'not-available' }) - ) - // Why: the generation fence stops at the autoUpdater spies; this one sits past it on the - // pinDefaultReleaseFeed chain that the stale instance's re-armed background check still reached. - expect(fetchNewerReleaseTagsMock).not.toHaveBeenCalled() - expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/usage/agent-token-usage-reporter.test.ts b/src/main/usage/agent-token-usage-reporter.test.ts index b7cae3de3b5..8ecf487838c 100644 --- a/src/main/usage/agent-token-usage-reporter.test.ts +++ b/src/main/usage/agent-token-usage-reporter.test.ts @@ -25,7 +25,7 @@ let directory: string let file: string let telemetry: TelemetryClientTestState let reporters: AgentTokenUsageReporter[] -const identity = vi.fn(async () => ID) +const identity = vi.fn(async (ids: readonly string[]) => ids.map(() => ID)) function reporter(): AgentTokenUsageReporter { const instance = new AgentTokenUsageReporter(file, 'claude', identity) reporters.push(instance) @@ -39,7 +39,7 @@ beforeEach(() => { file = join(directory, 'tokens.json') telemetry = setupTelemetryClientTest() reporters = [] - identity.mockReset().mockResolvedValue(ID) + identity.mockReset().mockImplementation(async (ids) => ids.map(() => ID)) }) afterEach(async () => { await Promise.all(reporters.map((instance) => instance.flush())) @@ -97,16 +97,34 @@ describe('agent token usage', () => { ) it('rechecks consent after asynchronous identity persistence', async () => { - identity.mockImplementation(async () => { + identity.mockImplementation(async (ids) => { if (telemetry.settings.telemetry) { telemetry.settings.telemetry.optedIn = false } - return ID + return ids.map(() => ID) }) await reporter().report([row]) expect(captures()).toEqual([]) }) + it('resolves every session identity in one batch', async () => { + const second = '00000000-0000-4000-8000-000000000002' + identity.mockImplementation(async (ids) => + ids.map((id) => (id === 'second-session' ? second : ID)) + ) + await reporter().report([ + row, + { ...row, providerSessionId: 'invalid-session', input_tokens: -1 }, + { ...row, providerSessionId: 'second-session', input_tokens: 7 } + ]) + expect(identity).toHaveBeenCalledOnce() + expect(identity).toHaveBeenCalledWith([row.providerSessionId, 'second-session']) + expect(captures()).toEqual([ + expect.objectContaining({ analytics_session_id: ID, input_tokens: 100 }), + expect.objectContaining({ analytics_session_id: second, input_tokens: 7 }) + ]) + }) + it('does not publish a revision whose write failed and retries it', async () => { const instance = reporter() const write = vi @@ -139,7 +157,6 @@ describe('agent token usage', () => { it('rejects invalid counts and unexpected content fields', async () => { await reporter().report([{ ...row, input_tokens: -1 }]) - expect(identity).not.toHaveBeenCalled() const payload = { ...row, analytics_session_id: ID, revision: 1, provider: 'claude' } expect(agentTokenUsageSchema.safeParse(payload).success).toBe(false) const { providerSessionId: _, ...valid } = payload diff --git a/src/main/usage/agent-token-usage-reporter.ts b/src/main/usage/agent-token-usage-reporter.ts index d79b605500a..12322f4eb55 100644 --- a/src/main/usage/agent-token-usage-reporter.ts +++ b/src/main/usage/agent-token-usage-reporter.ts @@ -26,7 +26,7 @@ export class AgentTokenUsageReporter { constructor( private readonly file: string, private readonly provider: AgentTokenUsage['provider'], - private readonly getSessionId: (providerSessionId: string) => Promise + private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise ) { this.writer = new UsageCacheSnapshotWriter('[agent-token-usage]', () => file) } @@ -43,21 +43,22 @@ export class AgentTokenUsageReporter { } private async publish(sessions: AgentTokenSession[]): Promise { + if (!isTelemetryEnabled()) { + return + } + const rows = sessions.flatMap(({ providerSessionId, ...counts }) => { + const parsed = agentTokenCountsSchema.safeParse(counts) + return parsed.success ? [{ providerSessionId, counts: parsed.data }] : [] + }) + const ids = await this.getSessionIds(rows.map((row) => row.providerSessionId)) if (!isTelemetryEnabled()) { return } const previous = this.snapshots ?? (await this.load()) const next = new Map(previous) let changed = false - for (const { providerSessionId, ...counts } of sessions) { - if (!isTelemetryEnabled()) { - return - } - const parsed = agentTokenCountsSchema.safeParse(counts) - if (!parsed.success) { - continue - } - const id = await this.getSessionId(providerSessionId) + for (const [index, { counts }] of rows.entries()) { + const id = ids[index] const existing = next.get(id) if ( existing && @@ -71,7 +72,7 @@ export class AgentTokenUsageReporter { next.set( id, agentTokenUsageSchema.parse({ - ...parsed.data, + ...counts, provider: this.provider, analytics_session_id: id, revision: (existing?.revision ?? 0) + 1 diff --git a/src/main/usage/analytics-session-id-store.test.ts b/src/main/usage/analytics-session-id-store.test.ts index 3ec5addfad2..02cd747104a 100644 --- a/src/main/usage/analytics-session-id-store.test.ts +++ b/src/main/usage/analytics-session-id-store.test.ts @@ -3,6 +3,7 @@ import type * as FsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import { AnalyticsSessionIdStore } from './analytics-session-id-store' type WriteGate = { entered: (() => void) | null; wait: Promise | null; fail: boolean } @@ -43,6 +44,10 @@ function createStore(path = file): AnalyticsSessionIdStore { stores.push(store) return store } +async function idFor(store: AnalyticsSessionIdStore, key: string) { + const [id] = await store.getOrCreate([key]) + return id +} beforeEach(() => { directory = mkdtempSync(join(tmpdir(), 'orca-analytics-session-id-')) file = join(directory, 'identities.json') @@ -61,49 +66,65 @@ describe('AnalyticsSessionIdStore', () => { it('is lazy and persists a random ID before returning it', async () => { const store = createStore() expect(existsSync(file)).toBe(false) - const id = await store.getOrCreate('provider-session-123') + const id = await idFor(store, 'provider-session-123') expect(id).toMatch(UUID_V4) expect(id).not.toContain('provider-session') expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ schemaVersion: 1, entries: [['provider-session-123', id]] }) - expect(await store.getOrCreate('provider-session-123')).toBe(id) + expect(await idFor(store, 'provider-session-123')).toBe(id) + }) + + it('persists a batch with one durable write and returns IDs in input order', async () => { + const store = createStore() + const [known] = await store.getOrCreate(['known']) + const write = vi.spyOn(UsageCacheSnapshotWriter.prototype, 'write') + const ids = await store.getOrCreate(['a', 'known', 'b', 'a']) + expect(write).toHaveBeenCalledOnce() + expect(ids[1]).toBe(known) + expect(ids[3]).toBe(ids[0]) + expect(new Set(ids).size).toBe(3) + expect(await createStore().getOrCreate(['b', 'a', 'known'])).toEqual([ids[2], ids[0], known]) + await store.getOrCreate(['known', 'a', 'b']) + expect(write).toHaveBeenCalledOnce() }) it('restores the ID after a restart or session resume', async () => { const original = createStore() - const id = await original.getOrCreate('resumed-session') + const id = await idFor(original, 'resumed-session') await original.flush() - expect(await createStore().getOrCreate('resumed-session')).toBe(id) + expect(await idFor(createStore(), 'resumed-session')).toBe(id) }) it('handles concurrent requests for the same and different sessions without lost writes', async () => { const store = createStore() const ids = await Promise.all( - Array.from({ length: 20 }, (_, index) => store.getOrCreate(`session-${index % 10}`)) + Array.from({ length: 20 }, (_, index) => idFor(store, `session-${index % 10}`)) ) expect(new Set(ids).size).toBe(10) expect(ids.slice(0, 10)).toEqual(ids.slice(10)) const restored = createStore() for (let index = 0; index < 10; index++) { - expect(await restored.getOrCreate(`session-${index}`)).toBe(ids[index]) + expect(await idFor(restored, `session-${index}`)).toBe(ids[index]) } }) it('isolates identical provider session IDs across provider and host/profile files', async () => { const ids = await Promise.all([ - createStore(join(directory, 'host-a', 'claude.json')).getOrCreate('same-session'), - createStore(join(directory, 'host-a', 'codex.json')).getOrCreate('same-session'), - createStore(join(directory, 'host-b', 'claude.json')).getOrCreate('same-session') + idFor(createStore(join(directory, 'host-a', 'claude.json')), 'same-session'), + idFor(createStore(join(directory, 'host-a', 'codex.json')), 'same-session'), + idFor(createStore(join(directory, 'host-b', 'claude.json')), 'same-session') ]) expect(new Set(ids).size).toBe(3) }) it.each(['', ' ', 'x'.repeat(1025)])( - 'rejects invalid keys without creating a mapping', + 'rejects a batch with an invalid key without minting any ID', async (key) => { - await expect(createStore().getOrCreate(key)).rejects.toThrow('Invalid provider session ID') + await expect(createStore().getOrCreate(['valid', key])).rejects.toThrow( + 'Invalid provider session ID' + ) expect(existsSync(file)).toBe(false) } ) @@ -111,10 +132,10 @@ describe('AnalyticsSessionIdStore', () => { it('handles prototype names and delimiter characters as ordinary opaque keys', async () => { const store = createStore() const keys = ['__proto__', 'constructor', 'a::b/c', 'a/b::c'] - const ids = await Promise.all(keys.map((key) => store.getOrCreate(key))) + const ids = await Promise.all(keys.map((key) => idFor(store, key))) expect(new Set(ids).size).toBe(keys.length) const restored = createStore() - expect(await Promise.all(keys.map((key) => restored.getOrCreate(key)))).toEqual(ids) + expect(await Promise.all(keys.map((key) => idFor(restored, key)))).toEqual(ids) }) it.each([ @@ -124,7 +145,7 @@ describe('AnalyticsSessionIdStore', () => { JSON.stringify({ schemaVersion: 1, entries: [], extra: 'secret' }) ])('fails closed on invalid persisted state without overwriting it', async (content) => { writeFileSync(file, content) - await expect(createStore().getOrCreate('new-session')).rejects.toThrow( + await expect(idFor(createStore(), 'new-session')).rejects.toThrow( 'Invalid analytics session identity file' ) expect(readFileSync(file, 'utf8')).toBe(content) @@ -144,7 +165,7 @@ describe('AnalyticsSessionIdStore', () => { ] ]) { writeFileSync(file, JSON.stringify({ schemaVersion: 1, entries })) - await expect(createStore().getOrCreate('new-session')).rejects.toThrow( + await expect(idFor(createStore(), 'new-session')).rejects.toThrow( 'Duplicate analytics session identity' ) } @@ -163,12 +184,12 @@ describe('AnalyticsSessionIdStore', () => { const store = createStore() let resolved = false let flushed = false - const first = store.getOrCreate('session').then((id) => { + const first = idFor(store, 'session').then((id) => { resolved = true return id }) await writing - const second = store.getOrCreate('session') + const second = idFor(store, 'session') const flush = store.flush().then(() => { flushed = true }) @@ -188,10 +209,10 @@ describe('AnalyticsSessionIdStore', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const store = createStore() writeGate.fail = true - await expect(store.getOrCreate('session')).rejects.toThrow('simulated disk failure') + await expect(idFor(store, 'session')).rejects.toThrow('simulated disk failure') expect(existsSync(file)).toBe(false) writeGate.fail = false - const id = await store.getOrCreate('session') - expect(await createStore().getOrCreate('session')).toBe(id) + const id = await idFor(store, 'session') + expect(await idFor(createStore(), 'session')).toBe(id) }) }) diff --git a/src/main/usage/analytics-session-id-store.ts b/src/main/usage/analytics-session-id-store.ts index 0b921e8efaa..1624732b131 100644 --- a/src/main/usage/analytics-session-id-store.ts +++ b/src/main/usage/analytics-session-id-store.ts @@ -25,25 +25,31 @@ export class AnalyticsSessionIdStore { constructor(private readonly file: string) {} - /** Resolves only after persistence succeeds, so an uploaded ID survives a restart. */ - async getOrCreate(providerSessionId: string): Promise { - if (!providerSessionIdSchema.safeParse(providerSessionId).success) { + /** IDs in input order; resolves only after persistence succeeds, so an uploaded ID survives a restart. */ + async getOrCreate(providerSessionIds: readonly string[]): Promise { + if (providerSessionIds.some((id) => !providerSessionIdSchema.safeParse(id).success)) { throw new Error('Invalid provider session ID') } + if (providerSessionIds.length === 0) { + return [] + } // Serialize reads as well as writes so no caller sees an ID before it is durable. const operation = this.pending.then(async () => { const identities = this.identities ?? (await this.load()) this.identities = identities - const existing = identities.get(providerSessionId) - if (existing) { - return existing + const updated = new Map(identities) + const ids = providerSessionIds.map((providerSessionId) => { + const id = updated.get(providerSessionId) ?? analyticsSessionIdSchema.parse(randomUUID()) + updated.set(providerSessionId, id) + return id + }) + // One durable write per batch: a first scan of N sessions must not rewrite the file N times. + if (updated.size > identities.size) { + this.writer ??= new UsageCacheSnapshotWriter('[analytics-session-id]', () => this.file) + await this.writer.write(() => JSON.stringify({ schemaVersion: 1, entries: [...updated] })) + this.identities = updated } - const id = analyticsSessionIdSchema.parse(randomUUID()) - const updated = new Map(identities).set(providerSessionId, id) - this.writer ??= new UsageCacheSnapshotWriter('[analytics-session-id]', () => this.file) - await this.writer.write(() => JSON.stringify({ schemaVersion: 1, entries: [...updated] })) - this.identities = updated - return id + return ids }) this.pending = operation.then( () => {}, diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts index ef8c820052a..cef33bf6d89 100644 --- a/src/main/usage/usage-provider-store-lifecycle.test.ts +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -197,12 +197,29 @@ describe('UsageProviderStoreLifecycle', () => { } }) + it('persists identities for a whole first scan with one write', async () => { + const telemetry = setupTelemetryClientTest() + try { + const sessions = Array.from({ length: 20 }, (_, index) => ({ id: `session-${index}` })) + scan.mockResolvedValue({ ...emptyScanResult(), sessions }) + const store = createStore(join(tempDirectory, 'provider.json')) + await store.setEnabled(true) + writeProbe.opens = 0 + await store.refresh(true) + // Usage cache, identity file, and token-usage snapshot: one write each. + expect(writeProbe.opens).toBe(3) + expect(telemetry.mock.capture).toHaveBeenCalledTimes(sessions.length) + } finally { + cleanupTelemetryClientTest(telemetry.envStash) + } + }) + it('keeps analytics identity separate from usage cache rebuilds and never puts it in snapshots', async () => { const cacheFile = join(tempDirectory, 'provider.json') const identityFile = join(tempDirectory, 'provider-analytics-session-ids.json') const original = createStore(cacheFile) expect(existsSync(identityFile)).toBe(false) - const id = await original.getAnalyticsSessionId('provider-session') + const [id] = await original.getAnalyticsSessionIds(['provider-session']) expect(existsSync(identityFile)).toBe(true) await original.setEnabled(true) await original.refresh(true) @@ -211,15 +228,15 @@ describe('UsageProviderStoreLifecycle', () => { expect(readFileSync(cacheFile, 'utf8')).not.toContain(id) rmSync(cacheFile) const rebuilt = createStore(cacheFile) - expect(await rebuilt.getAnalyticsSessionId('provider-session')).toBe(id) - expect(await createStore().getAnalyticsSessionId('provider-session')).not.toBe(id) + expect(await rebuilt.getAnalyticsSessionIds(['provider-session'])).toEqual([id]) + expect(await createStore().getAnalyticsSessionIds(['provider-session'])).not.toEqual([id]) }) it('flush waits for queued analytics identity creation', async () => { const store = createStore() - const identity = store.getAnalyticsSessionId('session') + const identity = store.getAnalyticsSessionIds(['session']) await store.flush() - const id = await identity + const [id] = await identity expect( readFileSync(join(tempDirectory, 'usage-0-analytics-session-ids.json'), 'utf8') ).toContain(id) diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts index bbfd4be8e13..550f79e3ce5 100644 --- a/src/main/usage/usage-provider-store-lifecycle.ts +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -87,14 +87,14 @@ export abstract class UsageProviderStoreLifecycle< } /** Local identity only; callers must use the usage store on the execution host. */ - getAnalyticsSessionId(providerSessionId: string): Promise { + getAnalyticsSessionIds(providerSessionIds: readonly string[]): Promise { if (!this.analyticsSessionIds) { const { dir, name } = parse(this.config.resolveCacheFile()) this.analyticsSessionIds = new AnalyticsSessionIdStore( join(dir, `${name}-analytics-session-ids.json`) ) } - return this.analyticsSessionIds.getOrCreate(providerSessionId) + return this.analyticsSessionIds.getOrCreate(providerSessionIds) } /** Await queued cache writes so quit does not drop the final snapshot. */ @@ -199,7 +199,7 @@ export abstract class UsageProviderStoreLifecycle< this.tokenReporter = new AgentTokenUsageReporter( join(dir, `${name}-token-usage.json`), config.provider, - (id) => this.getAnalyticsSessionId(id) + (ids) => this.getAnalyticsSessionIds(ids) ) } await this.tokenReporter.report(config.selectSessions(this.state)) diff --git a/src/main/usage/usage-scan-worker-client.test.ts b/src/main/usage/usage-scan-worker-client.test.ts index 7b34b5a80f4..09c6f378f00 100644 --- a/src/main/usage/usage-scan-worker-client.test.ts +++ b/src/main/usage/usage-scan-worker-client.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join, sep } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' @@ -235,16 +234,4 @@ describe('usage scan worker entry path', () => { USAGE_SCAN_WORKER_ENTRY_FILENAME ]) }) - - // A rename in the build config would leave both branches pointing at a file - // that is never emitted, and only the packaged one fails silently. - it('names the entry the main build actually emits', () => { - const config = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), - 'utf8' - ) - - expect(USAGE_SCAN_WORKER_ENTRY_FILENAME).toBe('usage-scan-worker-entry.js') - expect(config).toContain("'usage-scan-worker-entry': resolve(") - }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 5a7aab6b145..04caf8e894b 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { ipcMain, nativeTheme } from 'electron' import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { Store } from '../persistence' import { @@ -97,7 +97,8 @@ export function attachMainWindowServices( awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight, onCodexHomePtySpawned: options?.onCodexHomePtySpawned, - onPtyExit: options?.onPtyExit + onPtyExit: options?.onPtyExit, + systemPrefersDark: () => nativeTheme.shouldUseDarkColors } ) // Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1). diff --git a/src/main/workspace-trust-test-fixtures.ts b/src/main/workspace-trust-test-fixtures.ts new file mode 100644 index 00000000000..273c1449329 --- /dev/null +++ b/src/main/workspace-trust-test-fixtures.ts @@ -0,0 +1,32 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentTrustPreset } from './agent-trust-presets' + +/** Whether any trust entry for `preset` landed under `home` (Claude's config lives in the home). */ +export function workspaceTrustWritten(home: string, preset: AgentTrustPreset): boolean { + switch (preset) { + case 'claude': + return 'projects' in JSON.parse(readFileSync(join(home, '.claude.json'), 'utf-8')) + case 'codex': + return existsSync(join(home, '.codex', 'config.toml')) + case 'cursor': { + const projects = join(home, '.cursor', 'projects') + return existsSync(projects) && readdirSync(projects).length > 0 + } + case 'copilot': + return existsSync(join(home, '.copilot', 'config.json')) + case 'qoder': + return existsSync(join(home, '.qoder', 'settings.json')) + case 'antigravity': + return existsSync(join(home, '.gemini', 'antigravity-cli', 'settings.json')) + } +} + +/** A linked worktree at `worktree` whose main checkout is `mainCheckout`, as git lays it out. */ +export function linkGitWorktree(mainCheckout: string, worktree: string): void { + const gitDir = join(mainCheckout, '.git', 'worktrees', 'feature') + mkdirSync(gitDir, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + writeFileSync(join(worktree, '.git'), `gitdir: ${gitDir}\n`) + writeFileSync(join(gitDir, 'gitdir'), join(worktree, '.git')) +} diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts new file mode 100644 index 00000000000..9d2d2e32e71 --- /dev/null +++ b/src/main/worktree-removal-close-records.test.ts @@ -0,0 +1,225 @@ +import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +// Why this file exists: removing a workspace's session rows must take its close records with it, +// or they hold cap slots until the TTL and read as "emptied on purpose" for a new workspace at the +// same path. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { toSshExecutionHostId } from '../shared/execution-host' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { + MAX_CLOSED_TERMINAL_TAB_TOMBSTONES, + recordClosedTerminalTabTombstone, + type ClosedTerminalTabTombstonesByTabId +} from '../shared/closed-terminal-tab-tombstones' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) +} + +const REMOVED = 'repo-a::/workspace/removed' +const KEPT = 'repo-a::/workspace/kept' +const OTHER_REPO = 'repo-b::/workspace/other' +const SSH_HOST = toSshExecutionHostId('target-1') + +function records( + entries: [tabId: string, worktreeId: string][], + now = Date.now() +): ClosedTerminalTabTombstonesByTabId { + return Object.fromEntries( + entries.map(([tabId, worktreeId]) => [ + tabId, + { closedAt: now, worktreeId, reason: 'user' as const } + ]) + ) +} + +function sessionWithRecords(map: ClosedTerminalTabTombstonesByTabId) { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [REMOVED]: [], [KEPT]: [] }, + closedTerminalTabTombstonesByTabId: map + } +} + +describe('close records on workspace removal', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops only the removed worktree’s records', async () => { + const store = await createStore() + store.setWorktreeMeta(REMOVED, {}) + store.setWorkspaceSession( + sessionWithRecords( + records([ + ['closed-removed', REMOVED], + ['closed-kept', KEPT] + ]) + ) + ) + + store.removeWorktreeMeta(REMOVED) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-kept']) + }) + + it('drops the records in the SSH host’s partition on a remote removal', async () => { + const store = await createStore() + store.setWorktreeMeta(REMOVED, { hostId: SSH_HOST }) + store.setWorkspaceSession( + sessionWithRecords( + records([ + ['closed-removed', REMOVED], + ['closed-kept', KEPT] + ]) + ), + SSH_HOST + ) + + store.removeWorktreeMeta(REMOVED, SSH_HOST) + + expect( + Object.keys(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-kept']) + }) + + // Why: repo ids and paths repeat across hosts; the local owner of the same id is still live. + it('keeps a same-id worktree’s records on another host when one host’s copy is removed', async () => { + const store = await createStore() + const repo = { id: 'repo-a', displayName: 'A', badgeColor: 'gray', addedAt: 1 } + store.addRepo({ ...repo, path: '/workspace/repo-a' }) + store.addRepo({ + ...repo, + path: '/remote/repo-a', + connectionId: 'target-1', + executionHostId: SSH_HOST + }) + store.setWorktreeMeta(REMOVED, { hostId: SSH_HOST }) + store.setWorkspaceSession(sessionWithRecords(records([['local-tab', REMOVED]]))) + store.setWorkspaceSession(sessionWithRecords(records([['ssh-tab', REMOVED]])), SSH_HOST) + + store.removeWorktreeMeta(REMOVED, SSH_HOST) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['local-tab']) + expect(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId).toEqual({}) + }) + + it('drops a removed folder workspace’s records from every partition', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const folder = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Scratch' }) + const folderKey = folderWorkspaceKey(folder.id) + store.setWorkspaceSession( + sessionWithRecords( + records([ + ['closed-folder', folderKey], + ['closed-kept', KEPT] + ]) + ) + ) + store.setWorkspaceSession(sessionWithRecords(records([['ssh-folder', folderKey]])), SSH_HOST) + + expect(store.removeFolderWorkspace(folder.id)).toBe(true) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-kept']) + expect(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId).toEqual({}) + }) + + // Why the removed worktree has no meta here: records alone must still mark it as the repo's. + it('drops every worktree’s records when its project is removed, and keeps other projects’', async () => { + const store = await createStore() + store.addRepo({ + id: 'repo-a', + path: '/workspace/repo-a', + displayName: 'A', + badgeColor: 'gray', + addedAt: 1 + }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + closedTerminalTabTombstonesByTabId: records([ + ['closed-a', REMOVED], + ['closed-a2', KEPT], + ['closed-b', OTHER_REPO] + ]) + }) + + store.removeProject('repo-a') + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-b']) + }) + + // Why: dead records used to hold cap slots until their TTL, so fresh closes evicted a live one. + it('frees the removed worktree’s cap slots, so later closes keep a live worktree’s record', async () => { + const store = await createStore() + const now = Date.now() + const dead = Array.from( + { length: MAX_CLOSED_TERMINAL_TAB_TOMBSTONES - 1 }, + (_, index): [string, string] => [`dead-${index}`, REMOVED] + ) + store.setWorktreeMeta(REMOVED, {}) + store.setWorkspaceSession( + sessionWithRecords({ + ...records(dead, now), + ...records([['live-kept', KEPT]], now - 60_000) + }) + ) + + store.removeWorktreeMeta(REMOVED) + let map = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId + for (let index = 0; index < 2; index += 1) { + map = recordClosedTerminalTabTombstone( + map, + `fresh-${index}`, + { worktreeId: KEPT }, + now + index + ) + } + + expect(map?.['live-kept']).toBeDefined() + }) +}) diff --git a/src/main/wsl-home-cache.ts b/src/main/wsl-home-cache.ts index 368bf163102..c833b14bdae 100644 --- a/src/main/wsl-home-cache.ts +++ b/src/main/wsl-home-cache.ts @@ -1,19 +1,26 @@ const MAX_WSL_HOME_CACHE_ENTRIES = 64 const wslHomeCache = new Map() +// Why: WSL distro names are case-insensitive, and paths and callers spell them differently. +function cacheKey(distro: string): string { + return distro.toLowerCase() +} + export function getCachedWslHome(distro: string): string | undefined { - const home = wslHomeCache.get(distro) + const key = cacheKey(distro) + const home = wslHomeCache.get(key) if (home === undefined) { return undefined } - wslHomeCache.delete(distro) - wslHomeCache.set(distro, home) + wslHomeCache.delete(key) + wslHomeCache.set(key, home) return home } export function rememberWslHome(distro: string, home: string): string { - wslHomeCache.delete(distro) - wslHomeCache.set(distro, home) + const key = cacheKey(distro) + wslHomeCache.delete(key) + wslHomeCache.set(key, home) while (wslHomeCache.size > MAX_WSL_HOME_CACHE_ENTRIES) { const oldest = wslHomeCache.keys().next().value if (oldest === undefined) { @@ -25,7 +32,7 @@ export function rememberWslHome(distro: string, home: string): string { } export function hasCachedWslHome(distro: string): boolean { - return wslHomeCache.has(distro) + return wslHomeCache.has(cacheKey(distro)) } export function clearWslHomeCache(): void { diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 49f706e5741..4fb4f8d01cf 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -28,7 +28,7 @@ */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' -import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' +import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, @@ -47,8 +47,6 @@ export type ZshWrapperRestoreSpec = { remoteCliBinDir: boolean /** Orca's runtime CODEX_HOME. */ codexHome: boolean - /** The `codex()` wrapper that runs Orca's launch preflight. */ - codexLaunchPreflight: boolean } export type ZshStartupHookSpec = { @@ -127,8 +125,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { MIMOCODE_HOME_RESTORE, spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null, getPosixOmpShellWrapper(), - spec.restores.codexHome ? CODEX_HOME_RESTORE : null, - spec.restores.codexLaunchPreflight ? getPosixCodexShellLaunchPreflight() : null + spec.restores.codexHome ? CODEX_HOME_RESTORE : null ] } @@ -175,6 +172,8 @@ ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, featureGuard('overlay', getOverlayRestoreBlocks(spec)), + // Why outside the overlay guard: a system-default Codex home carries no overlay key. + indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file // ran, so the value it derives is the user's own path. #11044 is unreachable. ` if [[ -n "\${_orca_histfile:-}" ]]; then diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7e4b2def434..220b90c18a6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -8,7 +8,7 @@ import type { } from './api/agent-account-api' import type { HooksApi } from './api/agent-hook-api' import type { SkillsApi } from './api/agent-skill-api' -import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-status-api' +import type { AgentAwakeApi, AgentStatusApi } from './api/agent-status-api' import type { ClaudeUsageApi, CodexUsageApi, @@ -106,7 +106,6 @@ export type PreloadApi = { claudeAccounts: ClaudeAccountsApi cli: CliApi codexConfigSync: CodexConfigSyncApi - agentTrust: AgentTrustApi preflight: PreflightApi notifications: NotificationsApi onboarding: OnboardingApi diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 8cd8fb6a251..909f0fd20fb 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -48,14 +48,6 @@ export type AgentStatusApi = { transferPaneAuthority: (args: { fromPaneKey: string; toPaneKey: string; ptyId?: string }) => void } -export type AgentTrustApi = { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' - workspacePath: string - connectionId?: string - }) => Promise -} - export type AgentAwakeApi = { getStatus: () => Promise onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts deleted file mode 100644 index 9d87c62eb49..00000000000 --- a/src/preload/api/agent-trust-bridge.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ipcRenderer } from 'electron' -import type { PreloadApi } from '../api-types' - -export const agentTrustApi = { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' - workspacePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) -} satisfies PreloadApi['agentTrust'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 742a8168196..42cf236c14d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,7 +40,6 @@ import { codexAccountsApi } from './api/codex-accounts-bridge' import { claudeAccountsApi } from './api/claude-accounts-bridge' import { cliApi } from './api/cli-bridge' import { codexConfigSyncApi } from './api/codex-config-sync-bridge' -import { agentTrustApi } from './api/agent-trust-bridge' import { preflightApi } from './api/preflight-bridge' import { notificationsApi } from './api/notifications-bridge' import { onboardingApi } from './api/onboarding-bridge' @@ -140,7 +139,6 @@ const api = { claudeAccounts: claudeAccountsApi, cli: cliApi, codexConfigSync: codexConfigSyncApi, - agentTrust: agentTrustApi, preflight: preflightApi, notifications: notificationsApi, onboarding: onboardingApi, diff --git a/src/preload/renderer-process-memory-reader.test.ts b/src/preload/renderer-process-memory-reader.test.ts index 7e40574c984..759ebbfd53b 100644 --- a/src/preload/renderer-process-memory-reader.test.ts +++ b/src/preload/renderer-process-memory-reader.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { readRendererProcessMemory } from './renderer-process-memory-reader' // Why the partial type: Electron types `residentSet` as required, but Chromium @@ -38,10 +38,4 @@ describe('readRendererProcessMemory', () => { readRendererProcessMemory(source(async () => ({ private: Number.NaN, shared: 0 }))) ).resolves.toBeNull() }) - - it('does not call the API more than once per read', async () => { - const getProcessMemoryInfo = vi.fn(async () => ({ private: 2048, shared: 0 })) - await readRendererProcessMemory(source(getProcessMemoryInfo)) - expect(getProcessMemoryInfo).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/preload/updater-package-recovery.test.ts b/src/preload/updater-package-recovery.test.ts index 7b9da47e76b..15f3576de77 100644 --- a/src/preload/updater-package-recovery.test.ts +++ b/src/preload/updater-package-recovery.test.ts @@ -81,11 +81,4 @@ describe('native preload linux package recovery methods', () => { expect(invoke.mock.calls).toEqual([['updater:showLinuxPackage']]) }) - - it('surfaces a main-process validation rejection to the caller', async () => { - const api = await loadApi() - invoke.mockRejectedValueOnce(new Error('hash mismatch')) - - await expect(api.updater.getLinuxPackageInstallInstructions()).rejects.toThrow('hash mismatch') - }) }) diff --git a/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts b/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts new file mode 100644 index 00000000000..d207da0e07b --- /dev/null +++ b/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../shared/home-or-filesystem-root', () => ({ + isTooBroadToPreTrust: () => { + throw new Error('homedir unavailable') + } +})) + +import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' + +describe('applyRelayAgentWorkspaceTrust when the breadth guard fails', () => { + it.each(['claude', 'copilot', 'qoder'] as const)( + 'skips %s trust and never fails the spawn', + async (agent) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect( + applyRelayAgentWorkspaceTrust({ workspacePath: '/srv/wt' }, agent, {}, { wslShell: false }) + ).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + } + ) +}) diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts new file mode 100644 index 00000000000..bc970bdf2d2 --- /dev/null +++ b/src/relay/agent-workspace-trust-spawn.test.ts @@ -0,0 +1,253 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as Os from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentTrustPreset } from '../main/agent-trust-presets' +import type { TuiAgent } from '../shared/tui-agent' +import { linkGitWorktree, workspaceTrustWritten } from '../main/workspace-trust-test-fixtures' + +const state = vi.hoisted(() => ({ home: '' })) + +// Why: the non-Claude writers resolve their files from this process's home, as on a real relay. +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => state.home } +}) + +import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' +import { buildSshPtySpawnRequest } from '../main/providers/ssh-pty-spawn-request' + +const HOST_SHELL = { wslShell: false } +const ORIGINAL_CLAUDE_CONFIG = '{"oauthAccount":{"x":1}}' +const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [ + ['claude', 'claude'], + ['claude-agent-teams', 'claude'], + ['codex', 'codex'], + ['cursor', 'cursor'], + ['copilot', 'copilot'], + ['qoder', 'qoder'] +] +const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) => + ['claude', 'copilot', 'qoder'].includes(preset) +) +const ALL_PRESETS: AgentTrustPreset[] = [ + 'claude', + 'codex', + 'cursor', + 'copilot', + 'qoder', + 'antigravity' +] + +let root: string +let home: string +let workspace: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-agent-trust-'))) + home = join(root, 'home', 'me') + mkdirSync(home, { recursive: true }) + state.home = home + writeFileSync(join(home, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 }) + workspace = join(root, 'wt') + mkdirSync(workspace) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function nothingWritten(): boolean { + return ALL_PRESETS.every((preset) => !workspaceTrustWritten(home, preset)) +} + +describe('applyRelayAgentWorkspaceTrust', () => { + it.each(AGENTS_WITH_A_RELAY_WRITER)( + "writes %s trust on the relay host's own disk", + async (agent, preset) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + agent, + { HOME: home }, + HOST_SHELL + ) + expect(workspaceTrustWritten(home, preset)).toBe(true) + } + ) + + it("grants Claude in the remote host's own config, named by the merged spawn env", async () => { + const configDir = join(root, 'cfg') + mkdirSync(configDir) + writeFileSync(join(configDir, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 }) + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'claude', + { CLAUDE_CONFIG_DIR: configDir, HOME: home }, + HOST_SHELL + ) + expect(JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf-8'))).toEqual({ + oauthAccount: { x: 1 }, + projects: { [workspace]: { hasTrustDialogAccepted: true } } + }) + expect(readFileSync(join(home, '.claude.json'), 'utf-8')).toBe(ORIGINAL_CLAUDE_CONFIG) + }) + + it('never creates a Claude config file Claude has not written', async () => { + const emptyHome = join(root, 'empty-home') + mkdirSync(emptyHome) + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'claude', + { HOME: emptyHome }, + HOST_SHELL + ) + expect(existsSync(join(emptyHome, '.claude.json'))).toBe(false) + }) + + it('writes Codex trust into the CODEX_HOME the spawn env names', async () => { + const codexHome = join(root, 'codex-home') + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'codex', + { HOME: home, CODEX_HOME: codexHome }, + HOST_SHELL + ) + expect(readFileSync(join(codexHome, 'config.toml'), 'utf-8')).toContain( + `[projects."${workspace}"]` + ) + expect(workspaceTrustWritten(home, 'codex')).toBe(false) + }) + + it("stores a Codex worktree's own path, not the relay home it hangs off, as a local launch does", async () => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(home, worktree) + await applyRelayAgentWorkspaceTrust( + { workspacePath: worktree }, + 'codex', + { HOME: home }, + HOST_SHELL + ) + const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain(`[projects."${worktree}"]`) + expect(written).not.toContain(`[projects."${home}"]`) + }) + + it.each([ + ['codex', 'codex'], + ['cursor', 'cursor'] + ] as const)( + 'trusts the relay home for %s, whose trust there covers only the home', + async (agent, preset) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: home }, + agent, + { HOME: home }, + HOST_SHELL + ) + expect(workspaceTrustWritten(home, preset)).toBe(true) + } + ) + + const tooBroad: [string, () => string][] = [ + ['the relay home', () => home], + ['a folder containing the relay home', () => join(root, 'home')], + [ + 'a symlink to the relay home', + () => { + symlinkSync(home, join(workspace, 'home-link'), 'junction') + return join(workspace, 'home-link') + } + ] + ] + describe.each(tooBroad)('for %s', (_label, arrange) => { + it.each(AGENTS_THAT_INHERIT_TRUST)('writes no %s trust', async (agent) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: arrange() }, + agent, + { HOME: home }, + HOST_SHELL + ) + expect(nothingWritten()).toBe(true) + }) + }) + + it.each(AGENTS_WITH_A_RELAY_WRITER)( + "never writes this host's files for %s started in a WSL guest", + async (agent) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + agent, + { HOME: home }, + { wslShell: true } + ) + expect(nothingWritten()).toBe(true) + } + ) + + it('leaves Antigravity to ask, since its writer is unverified on SSH hosts', async () => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'antigravity', + { HOME: home }, + HOST_SHELL + ) + expect(workspaceTrustWritten(home, 'antigravity')).toBe(false) + }) + + it.each([ + ['no request', () => undefined, 'claude'], + ['a malformed request', () => ({ workspacePath: 42 }), 'claude'], + ['an earlier-shaped request', () => ({ worktreeRoot: workspace, trusted: true }), 'codex'], + ['an agent with no trust preset', () => ({ workspacePath: workspace }), 'gemini'], + ['no declared agent', () => ({ workspacePath: workspace }), undefined] + ] as const)('writes nothing, so the agent asks, for %s', async (_label, request, agent) => { + await applyRelayAgentWorkspaceTrust(request(), agent, { HOME: home }, HOST_SHELL) + expect(nothingWritten()).toBe(true) + }) + + it('never fails the spawn when a writer throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const codexHome = join(root, 'codex-home') + // Why: a config.toml that cannot be read as a file makes the Codex writer throw. + mkdirSync(join(codexHome, 'config.toml'), { recursive: true }) + await expect( + applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'codex', + { HOME: home, CODEX_HOME: codexHome }, + HOST_SHELL + ) + ).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) + +describe('buildSshPtySpawnRequest', () => { + it('forwards the workspace to trust as an optional field an old relay ignores', () => { + const request = buildSshPtySpawnRequest({ + options: { + cols: 80, + rows: 24, + launchAgent: 'codex', + agentWorkspaceTrust: { workspacePath: '/w' } + }, + supportsCreateOperation: false + }) + expect(request.agentWorkspaceTrust).toEqual({ workspacePath: '/w' }) + expect(request.launchAgent).toBe('codex') + expect( + buildSshPtySpawnRequest({ options: { cols: 80, rows: 24 }, supportsCreateOperation: false }) + ).not.toHaveProperty('agentWorkspaceTrust') + }) +}) diff --git a/src/relay/agent-workspace-trust-spawn.ts b/src/relay/agent-workspace-trust-spawn.ts new file mode 100644 index 00000000000..51eaaa28485 --- /dev/null +++ b/src/relay/agent-workspace-trust-spawn.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { parseAgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request' +import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config' +import type { TuiAgent } from '../shared/tui-agent' +import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trust-file' +import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline' +import { applyWorkspaceTrustOnThisHost } from '../main/execution-host-workspace-trust' + +/** + * Why here: this host owns the files the agent reads, so each writer's lock, re-read, atomic + * rename and refusal rules act on local disk, with no round trip across the SSH link. + */ +export async function applyRelayAgentWorkspaceTrust( + rawRequest: unknown, + launchAgent: TuiAgent | undefined, + spawnEnv: Record, + launch: { wslShell: boolean } +): Promise { + const request = parseAgentWorkspaceTrustSpawnRequest(rawRequest) + const preset = launchAgent ? TUI_AGENT_CONFIG[launchAgent].preflightTrust : undefined + // Why: an agent inside a WSL guest reads the guest's files, not this Windows host's, and + // Antigravity's writer is unverified on SSH hosts, so it still asks there. + if (!request || !preset || preset === 'antigravity' || launch.wslShell) { + return + } + await applyWorkspaceTrustOnThisHost(preset, request.workspacePath, () => { + const keyStyle = process.platform === 'win32' ? 'win32' : 'posix' + const homeDir = (keyStyle === 'win32' ? spawnEnv.USERPROFILE : spawnEnv.HOME) || homedir() + return { + homes: [homeDir, homedir()], + claudeConfig: () => ({ + configFile: resolveClaudeGlobalConfigFile({ + env: spawnEnv, + homeDir, + style: keyStyle, + exists: existsSync + }), + keyStyle + }), + codexConfigFiles: () => [join(spawnEnv.CODEX_HOME || join(homeDir, '.codex'), 'config.toml')], + // Why: every write here is on the relay's own disk, so each preset gets the local budget. + deadlineMs: SHORT_AGENT_TRUST_WRITE_DEADLINE_MS + } + }) +} diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts index 71d09c78de6..f6b7cee2844 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync } from 'node:fs' -import { rm, readFile, stat } from 'node:fs/promises' +import { rm, readFile } from 'node:fs/promises' import * as path from 'node:path' import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' @@ -119,6 +119,21 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { expect(result.isBinary).toBe(false) }) + // Why this one stays: writeRelayFile resolves to undefined, so the response frame + // carries neither `result` nor `error` — the one payload shape the other cases here + // do not produce, and one a mock dispatcher never has to settle. The client sees + // `null`, not `undefined`: the member is absent from the frame, not carried as void. + it('writeFile creates/overwrites file content', async () => { + const filePath = path.join(tmpDir, 'output.txt') + + await expect( + mux.request('fs.writeFile', { filePath, content: 'written via relay' }) + ).resolves.toBeNull() + + const content = await readFile(filePath, 'utf-8') + expect(content).toBe('written via relay') + }) + it('readFileStream round-trip preserves a 12 MB binary file', async () => { const filePath = path.join(tmpDir, 'big.png') const original = randomBytes(12 * 1024 * 1024) @@ -127,78 +142,6 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { const { content } = await readFileViaStream(mux, filePath) expect(Buffer.from(content, 'base64').equals(original)).toBe(true) }, 30_000) - - it('writeFile creates/overwrites file content', async () => { - const filePath = path.join(tmpDir, 'output.txt') - - await mux.request('fs.writeFile', { filePath, content: 'written via relay' }) - - const content = await readFile(filePath, 'utf-8') - expect(content).toBe('written via relay') - }) - - it('stat returns file metadata', async () => { - writeFileSync(path.join(tmpDir, 'sized.txt'), 'abcdef') - const result = (await mux.request('fs.stat', { - filePath: path.join(tmpDir, 'sized.txt') - })) as { size: number; type: string; mtime: number } - expect(result).toMatchObject({ type: 'file', size: 6 }) - expect(typeof result.mtime).toBe('number') - }) - - it('createFile + deletePath roundtrip', async () => { - const filePath = path.join(tmpDir, 'nested', 'deep', 'new.txt') - - await mux.request('fs.createFile', { filePath }) - const s = await stat(filePath) - expect(s.isFile()).toBe(true) - - await mux.request('fs.deletePath', { targetPath: filePath }) - await expect(stat(filePath)).rejects.toThrow() - }) - - it('createDir creates directories recursively', async () => { - const dirPath = path.join(tmpDir, 'a', 'b', 'c') - - await mux.request('fs.createDir', { dirPath }) - const s = await stat(dirPath) - expect(s.isDirectory()).toBe(true) - }) - - it('rename moves files', async () => { - const oldPath = path.join(tmpDir, 'before.txt') - const newPath = path.join(tmpDir, 'after.txt') - writeFileSync(oldPath, 'moving') - - await mux.request('fs.rename', { oldPath, newPath }) - - await expect(stat(oldPath)).rejects.toThrow() - const content = await readFile(newPath, 'utf-8') - expect(content).toBe('moving') - }) - - it('copy duplicates files', async () => { - const src = path.join(tmpDir, 'src.txt') - const dst = path.join(tmpDir, 'dst.txt') - writeFileSync(src, 'original') - - await mux.request('fs.copy', { source: src, destination: dst }) - - const content = await readFile(dst, 'utf-8') - expect(content).toBe('original') - }) - - it('readFile returns error for non-existent file', async () => { - await expect( - mux.request('fs.readFile', { filePath: path.join(tmpDir, 'nope.txt') }) - ).rejects.toThrow() - }) - - it('errors propagate correctly through the protocol', async () => { - await expect( - mux.request('fs.stat', { filePath: '/nonexistent/path/that/does/not/exist' }) - ).rejects.toThrow() - }) }) // ─── Git ──────────────────────────────────────────────────────── @@ -210,66 +153,6 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { gitCommit(tmpDir, 'initial commit') }) - it('git.status returns clean status for committed repo', async () => { - const result = (await mux.request('git.status', { - worktreePath: tmpDir - })) as { entries: unknown[]; conflictOperation: string } - - expect(result.entries).toEqual([]) - expect(result.conflictOperation).toBe('unknown') - }) - - it('git.status detects modifications', async () => { - writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') - - const result = (await mux.request('git.status', { - worktreePath: tmpDir - })) as { entries: { path: string; status: string; area: string }[] } - - const entry = result.entries.find((e) => e.path === 'file.txt') - expect(entry).toBeDefined() - expect(entry!.status).toBe('modified') - expect(entry!.area).toBe('unstaged') - }) - - it('git.status detects untracked files', async () => { - writeFileSync(path.join(tmpDir, 'new.txt'), 'new') - - const result = (await mux.request('git.status', { - worktreePath: tmpDir - })) as { entries: { path: string; status: string; area: string }[] } - - const entry = result.entries.find((e) => e.path === 'new.txt') - expect(entry).toBeDefined() - expect(entry!.status).toBe('untracked') - }) - - it('git.stage + git.status shows staged entry', async () => { - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - - await mux.request('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) - - const result = (await mux.request('git.status', { - worktreePath: tmpDir - })) as { entries: { path: string; area: string }[] } - - const staged = result.entries.find((e) => e.area === 'staged') - expect(staged).toBeDefined() - }) - - it('git.unstage reverses staging', async () => { - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - await mux.request('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) - await mux.request('git.unstage', { worktreePath: tmpDir, filePath: 'file.txt' }) - - const result = (await mux.request('git.status', { - worktreePath: tmpDir - })) as { entries: { area: string }[] } - - const staged = result.entries.filter((e) => e.area === 'staged') - expect(staged.length).toBe(0) - }) - it('git.diff returns original and modified content', async () => { writeFileSync(path.join(tmpDir, 'file.txt'), 'updated content') @@ -283,91 +166,6 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { expect(result.originalContent).toBe('initial') expect(result.modifiedContent).toBe('updated content') }) - - it('git.diff returns staged diff', async () => { - writeFileSync(path.join(tmpDir, 'file.txt'), 'staged version') - execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) - - const result = (await mux.request('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: true - })) as { originalContent: string; modifiedContent: string } - - expect(result.originalContent).toBe('initial') - expect(result.modifiedContent).toBe('staged version') - }) - - it('git.discard restores tracked file to HEAD', async () => { - writeFileSync(path.join(tmpDir, 'file.txt'), 'dirty') - - await mux.request('git.discard', { worktreePath: tmpDir, filePath: 'file.txt' }) - - const content = await readFile(path.join(tmpDir, 'file.txt'), 'utf-8') - expect(content).toBe('initial') - }) - - it('git.discard removes untracked file', async () => { - writeFileSync(path.join(tmpDir, 'temp.txt'), 'throwaway') - - await mux.request('git.discard', { worktreePath: tmpDir, filePath: 'temp.txt' }) - - await expect(stat(path.join(tmpDir, 'temp.txt'))).rejects.toThrow() - }) - - it('git.conflictOperation returns unknown for normal repo', async () => { - const result = await mux.request('git.conflictOperation', { - worktreePath: tmpDir - }) - expect(result).toBe('unknown') - }) - - it('git.listWorktrees returns the main worktree', async () => { - const result = (await mux.request('git.listWorktrees', { - repoPath: tmpDir - })) as { path: string; isMainWorktree: boolean }[] - - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result[0].isMainWorktree).toBe(true) - }) - - it('git.branchCompare works across branches', async () => { - // Get current branch name (might be "main" or "master" depending on config) - const defaultBranch = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature work') - gitCommit(tmpDir, 'feature commit') - - const result = (await mux.request('git.branchCompare', { - worktreePath: tmpDir, - baseRef: defaultBranch - })) as { summary: { status: string; commitsAhead: number }; entries: unknown[] } - - expect(result.summary.status).toBe('ready') - expect(result.summary.commitsAhead).toBe(1) - expect(result.entries.length).toBe(1) - }) - - it('git.bulkStage stages multiple files at once', async () => { - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - - await mux.request('git.bulkStage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] - }) - - const result = (await mux.request('git.status', { - worktreePath: tmpDir - })) as { entries: { path: string; area: string }[] } - - const staged = result.entries.filter((e) => e.area === 'staged') - expect(staged.length).toBe(2) - }) }) // ─── Error propagation ────────────────────────────────────────── diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index e547e153cc9..6d17b78f543 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -315,16 +315,6 @@ describe('parseHexAddress', () => { expect(result).toEqual({ host: '0.0.0.0', port: 8080 }) }) - it('parses port 22 correctly', () => { - const result = parseHexAddress('00000000:0016') - expect(result).toEqual({ host: '0.0.0.0', port: 22 }) - }) - - it('parses port 443 correctly', () => { - const result = parseHexAddress('0100007F:01BB') - expect(result).toEqual({ host: '127.0.0.1', port: 443 }) - }) - it('parses a non-localhost IPv4 address', () => { // 192.168.1.100 in little-endian: 6401A8C0 const result = parseHexAddress('6401A8C0:1388') @@ -351,22 +341,6 @@ describe('parseHexAddress', () => { expect(parseHexAddress('')).toBeNull() expect(parseHexAddress('::::')).toBeNull() }) - - it('parses high ports correctly', () => { - // Port 65535 = FFFF - const result = parseHexAddress('0100007F:FFFF') - expect(result).toEqual({ host: '127.0.0.1', port: 65535 }) - }) - - it('parses port 5432 (postgres)', () => { - const result = parseHexAddress('0100007F:1538') - expect(result).toEqual({ host: '127.0.0.1', port: 5432 }) - }) - - it('parses port 3306 (mysql)', () => { - const result = parseHexAddress('00000000:0CEA') - expect(result).toEqual({ host: '0.0.0.0', port: 3306 }) - }) }) describe('parseWindowsPowerShellPortRows', () => { diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 211c85bceb3..b5d3cc73936 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -34,7 +34,6 @@ vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) import { - buildCommandLookupSpec, buildCommandLookupSpecs, hasAbsoluteCommandPath, isCommandOnPathForRelay, @@ -74,39 +73,6 @@ beforeEach(() => { isGitBashAvailableMock.mockReset() }) -describe('buildCommandLookupSpec', () => { - it('uses where.exe on native Windows SSH hosts', () => { - expect(buildCommandLookupSpec('codex', 'win32')).toEqual({ - file: 'where.exe', - args: ['codex'], - windowsHide: true - }) - }) - - it('falls back to sh for POSIX probes without a configured shell', () => { - expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ - file: '/bin/sh', - args: lookupArgs('codex') - }) - }) - - it('uses the configured remote shell for POSIX probes', () => { - expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ - file: '/bin/zsh', - args: lookupArgs('codex', '-ilc') - }) - }) - - it('quotes command names in shell probes', () => { - expect( - buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') - ).toEqual({ - file: '/bin/bash', - args: lookupArgs("agent'cli", '-ilc') - }) - }) -}) - describe('buildCommandLookupSpecs', () => { it('falls back to inherited PATH after a trusted configured POSIX shell', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ @@ -216,27 +182,9 @@ describe('isCommandOnPathForRelay', () => { }) describe('hasAbsoluteCommandPath', () => { - it('ignores banners and shell function output', () => { - expect(hasAbsoluteCommandPath('/tmp/not-the-agent\ncodex is a shell function\n', 'linux')).toBe( - false - ) - }) - it('ignores unmarked POSIX absolute paths from shell startup output', () => { expect(hasAbsoluteCommandPath('/tmp/not-the-agent\n', 'linux')).toBe(false) }) - - it('recognizes a sentinel-marked command path amid shell startup and exit output', () => { - expect( - hasAbsoluteCommandPath('welcome\n__ORCA_AGENT_PATH__/opt/bin/codex\nlogout-banner\n', 'linux') - ).toBe(true) - }) - - it('recognizes Windows absolute command paths', () => { - expect( - hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32') - ).toBe(true) - }) }) describe('PreflightHandler', () => { diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 1be1a63f9d4..a98a32af3a8 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -172,16 +172,6 @@ function isDetectionUnsupportedInRuntime( return command.unsupportedRuntimes?.includes(runtime) === true } -export function buildCommandLookupSpec( - command: string, - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv = process.env, - accountLoginShell?: string | null -): CommandLookupSpec { - const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell) - return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh') -} - export function buildCommandLookupSpecs( command: string, platform: NodeJS.Platform, diff --git a/src/relay/pty-handler-agent-workspace-trust.test.ts b/src/relay/pty-handler-agent-workspace-trust.test.ts new file mode 100644 index 00000000000..3acdda6ab2e --- /dev/null +++ b/src/relay/pty-handler-agent-workspace-trust.test.ts @@ -0,0 +1,96 @@ +import './mock-descendant-sweep' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe, mockApplyTrust } = + vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockApplyTrust: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } + })) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + +vi.mock('./agent-workspace-trust-spawn', () => ({ + applyRelayAgentWorkspaceTrust: mockApplyTrust +})) + +import type { PtyHandler } from './pty-handler' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import type { MockDispatcher } from './pty-handler-test-harness' + +describe('relay pty.spawn agent workspace trust', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let root: string + + beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + mockApplyTrust.mockReset() + root = mkdtempSync(join(tmpdir(), 'orca-relay-trust-spawn-')) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + rmSync(root, { recursive: true, force: true }) + }) + + it("writes the launch's trust with its final env before the agent's process starts", async () => { + let finishTrust = (): void => {} + mockApplyTrust.mockReturnValue( + new Promise((resolve) => { + finishTrust = resolve + }) + ) + const request = { workspacePath: root } + + const spawned = dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + cwd: root, + launchAgent: 'codex', + env: { CODEX_HOME: '/remote/codex-home' }, + agentWorkspaceTrust: request + }) + await vi.advanceTimersByTimeAsync(0) + + expect(mockApplyTrust).toHaveBeenCalledWith( + request, + 'codex', + expect.objectContaining({ CODEX_HOME: '/remote/codex-home' }), + { wslShell: false } + ) + expect(mockPtySpawn).not.toHaveBeenCalled() + finishTrust() + await spawned + expect(mockPtySpawn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts index 5ec55a6eb2a..571290e59cf 100644 --- a/src/relay/pty-handler-attach-replay.test.ts +++ b/src/relay/pty-handler-attach-replay.test.ts @@ -501,26 +501,10 @@ describe('PtyHandler', () => { }) describe('attachIdentityMismatches', () => { - it('rejects a paneKey collision across relay generations', () => { - // Old lease expects tab-a's pane; the reset relay's pty-1 belongs to tab-b. - expect( - attachIdentityMismatches({ paneKey: 'tab-a:0' }, { paneKey: 'tab-b:0', tabId: 'tab-b' }) - ).toBe(true) - }) - it('rejects a tabId collision when only tab identity is known', () => { expect(attachIdentityMismatches({ tabId: 'tab-a' }, { tabId: 'tab-b' })).toBe(true) }) - it('accepts a matching identity', () => { - expect( - attachIdentityMismatches( - { paneKey: 'tab-a:0', tabId: 'tab-a' }, - { paneKey: 'tab-a:0', tabId: 'tab-a' } - ) - ).toBe(false) - }) - it('stays permissive when the caller supplies no identity', () => { expect(attachIdentityMismatches({}, { paneKey: 'tab-a:0', tabId: 'tab-a' })).toBe(false) }) diff --git a/src/relay/pty-handler-output-streaming.test.ts b/src/relay/pty-handler-output-streaming.test.ts index 1030d98ca42..f9e241e04f0 100644 --- a/src/relay/pty-handler-output-streaming.test.ts +++ b/src/relay/pty-handler-output-streaming.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import './mock-descendant-sweep' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' +import { _resetPtyOwnerHostColorsForTest } from '../shared/pty-owner-color-query-colors' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -167,13 +168,16 @@ describe('PtyHandler', () => { ) onData!(raw) vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: expect.stringContaining('"state":"blocked"'), - rawLength: raw.length, - seq: raw.length, - transformed: true - }) + // The capture opens with Freebuff's OSC 10/11 probes, which the relay answers and strips. + expect(dispatcher.notify).toHaveBeenCalledWith( + 'pty.data', + expect.objectContaining({ + id: PTY_1, + data: expect.stringContaining('"state":"blocked"'), + seq: raw.length, + transformed: true + }) + ) dispatcher.notify.mockClear() onData!('more output') vi.advanceTimersByTime(8) @@ -389,7 +393,7 @@ describe('PtyHandler', () => { }) }) - it('leaves startup queries untouched for an unsupported relay capability version', async () => { + it('ignores an intent from another ingress version and answers from the default theme', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) try { @@ -414,8 +418,8 @@ describe('PtyHandler', () => { dataCallback!(query) vi.advanceTimersByTime(8) - expect(term.write).not.toHaveBeenCalled() - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: query }) + expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:ffff/ffff/ffff\x1b\\') + expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', { id: PTY_1, data: query }) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -423,7 +427,7 @@ describe('PtyHandler', () => { } }) - it('consumes a color query at a native Windows SSH relay owner', async () => { + it('answers a color query at a native Windows SSH relay owner', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) try { @@ -441,7 +445,7 @@ describe('PtyHandler', () => { dataCallback!('\x1b]10;?\x07') vi.advanceTimersByTime(8) - expect(term.write).not.toHaveBeenCalled() + expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:ffff/ffff/ffff\x1b\\') expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: '', @@ -456,33 +460,45 @@ describe('PtyHandler', () => { } }) - it('forwards color queries from a POSIX SSH relay owner', async () => { + it('answers a POSIX SSH relay query from the latest relay-wide colours', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) try { let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ + const term = { ...mockPtyInstance, onData: vi.fn((cb: (data: string) => void) => { dataCallback = cb }), onExit: vi.fn() - }) + } + mockPtySpawn.mockReturnValue(term) await dispatcher.callRequest('pty.spawn', { shellOverride: '/bin/bash' }) - const query = '\x1b]10;?\x07' + const query = '\x1b]11;?\x07' + dispatcher.callNotification('pty.setColorQueryReplyColors', { + colors: { foreground: '#000000', background: '#123456' } + }) + dataCallback!(query) + // A malformed push keeps the previous colours rather than blanking them. + dispatcher.callNotification('pty.setColorQueryReplyColors', { colors: { background: 7 } }) dataCallback!(query) vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: query }) + expect(term.write.mock.calls).toEqual([ + ['\x1b]11;rgb:1212/3434/5656\x1b\\'], + ['\x1b]11;rgb:1212/3434/5656\x1b\\'] + ]) + expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', { id: PTY_1, data: query }) } finally { + _resetPtyOwnerHostColorsForTest() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } } }) - it('keeps renderer color replies for a Windows SSH relay that owns WSL', async () => { + it('answers a Windows SSH relay query for a WSL shell at the owner too', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) try { @@ -499,17 +515,15 @@ describe('PtyHandler', () => { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }) - const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' dataCallback!('\x1b]11;?\x07') vi.advanceTimersByTime(8) - dispatcher.callNotification('pty.data', { id: PTY_1, data: reply }) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + expect(term.write).toHaveBeenCalledWith('\x1b]11;rgb:2828/2c2c/3434\x1b\\') + expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', { id: PTY_1, data: '\x1b]11;?\x07' }) - expect(term.write).toHaveBeenCalledWith(reply) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -699,6 +713,41 @@ describe('PtyHandler', () => { }) }) + it('does not split a bounded slice inside an open DEC 2026 frame', async () => { + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + + await dispatcher.callRequest('pty.spawn', {}) + // A frame that closes just before the 16KB boundary, then a second frame + // that straddles it. Cutting at the raw boundary would strand the second + // frame's \x1b[?2026l, and xterm paints nothing while the latch is open. + const open = '\x1b[?2026h' + const close = '\x1b[?2026l' + const firstFrame = `${open}${'x'.repeat(16 * 1024 - 2 * open.length - close.length)}${close}` + const secondFrame = `${open}${'y'.repeat(64)}${close}` + dataCallback!(`${firstFrame}${secondFrame}`) + + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledTimes(1) + expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', { + id: PTY_1, + data: firstFrame + }) + + vi.advanceTimersByTime(1) + expect(dispatcher.notify).toHaveBeenCalledTimes(2) + expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.data', { + id: PTY_1, + data: secondFrame + }) + }) + it('writes data to PTY via pty.data notification', async () => { const mockWrite = vi.fn() mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index 22993322a9c..659d9fbc627 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -336,6 +336,44 @@ describe('PtyHandler', () => { } ) + it.each(['process', 'client'])( + 'drops an ORCA_CODEX_LAUNCH_PREFLIGHT from the relay %s env', + async (source) => { + const inherited = '/opt/orca/bin/orca' + const previous = process.env.ORCA_CODEX_LAUNCH_PREFLIGHT + if (source === 'process') { + process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = inherited + } + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + ...(source === 'client' ? { env: { ORCA_CODEX_LAUNCH_PREFLIGHT: inherited } } : {}) + }) + } finally { + if (previous === undefined) { + delete process.env.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = previous + } + } + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + } + ) + + it('keeps the Codex server opt-out the client asked for', async () => { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_CODEX_ISOLATE: '0' } + }) + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_CODEX_ISOLATE).toBe('0') + }) + it('drops an ORCA_HISTFILE handed over in the client env', async () => { await dispatcher.callRequest('pty.spawn', { cols: 80, diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 286bfdb89a7..b6714a956b2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,5 +1,7 @@ -import { FreebuffStatusProjection } from './freebuff-status-projection' /* oxlint-disable max-lines */ +import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchronized-output-scan' +import { FreebuffStatusProjection } from './freebuff-status-projection' +import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' import type * as NodePty from 'node-pty' @@ -77,6 +79,7 @@ import { type PtyIngressEmission } from '../shared/pty-startup-ingress' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' +import { setPtyOwnerHostColors } from '../shared/pty-owner-color-query-colors' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' import { TerminalShellRecoveryBarrier } from '../main/daemon/terminal-shell-recovery-barrier' import { confirmPtyShellForeground } from '../main/daemon/pty-subprocess/pty-shell-foreground-confirmation' @@ -845,6 +848,8 @@ export class PtyHandler { // pane to another worktree's history file — and wrapping a zsh pane that // nothing asked to wrap, since `history` is selected on its presence. delete result.ORCA_HISTFILE + // Why: the codex wrapper runs this path as hook prep, and a relay pane never gets one of its own. + delete result.ORCA_CODEX_LAUNCH_PREFLIGHT // Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values. for (const key of envToDelete) { delete result[key] @@ -1148,6 +1153,10 @@ export class PtyHandler { this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) + // A notification, so a client newer than this relay is ignored rather than refused. + this.dispatcher.onNotification('pty.setColorQueryReplyColors', (p) => + setPtyOwnerHostColors(p.colors) + ) } private isLikelyInteractiveRedraw(data: string): boolean { @@ -1340,6 +1349,19 @@ export class PtyHandler { ? desiredChars : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? desiredChars) + // Why before the surrogate guard: splitting inside an open DEC 2026 frame + // strands the closing \x1b[?2026l in the remainder, and xterm stops repainting + // until it arrives or its 1000ms timeout fires. The surrogate guard keeps the + // final say so a frame boundary can never sever a pair. + if (!pending.transformed && !pending.sourceChunk && chunkChars > 0) { + const frameAligned = resolveSynchronizedOutputSafeSplit(pending.data, chunkChars) + // Why the floor of 2: the surrogate guard below can decrement by one, and + // a chunkChars of 0 takes the pause-and-retry path. Never let frame + // alignment walk a healthy slice into that. + if (frameAligned >= 2) { + chunkChars = frameAligned + } + } if ( chunkChars > 0 && chunkChars < pending.data.length && @@ -1920,6 +1942,9 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { + wslShell: isRelayWslShell(shell) + }) const worktreeId = typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID const historyIsolationEnabled = params.historyIsolationEnabled === true diff --git a/src/relay/pty-replay-buffer-equivalence.test.ts b/src/relay/pty-replay-buffer-equivalence.test.ts deleted file mode 100644 index 57b35ab8480..00000000000 --- a/src/relay/pty-replay-buffer-equivalence.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { REPLAY_BUFFER_MAX } from './pty-handler' - -// Reference: the pre-change replay buffer, a rolling string sliced per append. -function appendStringTail(previous: string, data: string): string { - if (data.length === 0) { - return previous - } - const next = previous + data - return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next -} - -function makeBuffer(): RecentPtyOutputBuffer { - return new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit: REPLAY_BUFFER_MAX }) -} - -function replayEquals(chunks: string[]): { deque: string; reference: string } { - const buffer = makeBuffer() - let reference = '' - for (const chunk of chunks) { - buffer.append(chunk) - reference = appendStringTail(reference, chunk) - } - return { deque: buffer.read(), reference } -} - -// Deterministic PRNG so a failure is reproducible. -function makeRandom(seed: number): () => number { - let state = seed >>> 0 - return () => { - state = (state * 1664525 + 1013904223) >>> 0 - return state / 0x100000000 - } -} - -describe('relay replay buffer equivalence', () => { - it('matches the rolling-string tail below the cap', () => { - const { deque, reference } = replayEquals(['hello ', 'world', '\r\n$ ']) - expect(deque).toBe(reference) - expect(deque).toBe('hello world\r\n$ ') - }) - - it('matches once the window saturates', () => { - const chunks = Array.from({ length: 40 }, (_value, index) => `chunk-${index}-`.repeat(400)) - const { deque, reference } = replayEquals(chunks) - expect(deque.length).toBe(REPLAY_BUFFER_MAX) - expect(deque).toBe(reference) - }) - - it('matches when one append alone exceeds the cap', () => { - const { deque, reference } = replayEquals(['x'.repeat(REPLAY_BUFFER_MAX * 2 + 7)]) - expect(deque.length).toBe(REPLAY_BUFFER_MAX) - expect(deque).toBe(reference) - }) - - it('matches when an oversized append follows existing content', () => { - const { deque, reference } = replayEquals(['prefix', 'y'.repeat(REPLAY_BUFFER_MAX + 3)]) - expect(deque).toBe(reference) - expect(deque.startsWith('prefix')).toBe(false) - }) - - it('matches at exactly the cap boundary', () => { - for (const total of [REPLAY_BUFFER_MAX - 1, REPLAY_BUFFER_MAX, REPLAY_BUFFER_MAX + 1]) { - const { deque, reference } = replayEquals(['a'.repeat(total)]) - expect(deque).toBe(reference) - } - }) - - it('ignores empty appends exactly as the string form did', () => { - const { deque, reference } = replayEquals(['a', '', 'b', '', 'c']) - expect(deque).toBe(reference) - expect(deque).toBe('abc') - }) - - it('is stable across repeated reads', () => { - const buffer = makeBuffer() - for (let index = 0; index < 200; index += 1) { - buffer.append(`line ${index}\r\n`.repeat(30)) - } - expect(buffer.read()).toBe(buffer.read()) - }) - - it('keeps appending correctly after a read collapses the deque', () => { - const buffer = makeBuffer() - let reference = '' - for (let index = 0; index < 60; index += 1) { - const chunk = `mid-${index}-`.repeat(300) - buffer.append(chunk) - reference = appendStringTail(reference, chunk) - // Interleave reads: attach/adopt/revive can land at any point in the stream. - if (index % 7 === 0) { - expect(buffer.read()).toBe(reference) - } - } - expect(buffer.read()).toBe(reference) - }) - - // Why fuzz: the deque trims across chunk boundaries with a deferred head offset, - // so the risky cases are irregular chunk sizes straddling the cap repeatedly. - it('matches the rolling string across randomized chunk streams', () => { - for (let seed = 1; seed <= 40; seed += 1) { - const random = makeRandom(seed) - const buffer = makeBuffer() - let reference = '' - for (let step = 0; step < 120; step += 1) { - const size = Math.floor(random() * (REPLAY_BUFFER_MAX / 8)) - const chunk = String.fromCharCode(97 + (step % 26)).repeat(size) - buffer.append(chunk) - reference = appendStringTail(reference, chunk) - } - expect(buffer.read(), `seed ${seed}`).toBe(reference) - } - }) - - // Why surrogates: trimming by code unit can split a pair, and the string form did - // exactly the same thing. The deque must not "fix" it into a different tail. - it('splits surrogate pairs identically to the rolling string', () => { - // Why the trailing unit: the cap is even and a pair is 2 units, so an emoji run - // alone always cuts on a pair boundary. One odd unit shifts the cut mid-pair. - const { deque, reference } = replayEquals([`${'\u{1F600}'.repeat(REPLAY_BUFFER_MAX)}z`]) - expect(deque).toBe(reference) - expect(deque.length).toBe(REPLAY_BUFFER_MAX) - const leadUnit = deque.charCodeAt(0) - expect(leadUnit).toBeGreaterThanOrEqual(0xdc00) - expect(leadUnit).toBeLessThanOrEqual(0xdfff) - }) - - it('keeps a pair-aligned tail intact when the cut lands on a boundary', () => { - const { deque, reference } = replayEquals(['\u{1F600}'.repeat(REPLAY_BUFFER_MAX)]) - expect(deque).toBe(reference) - const leadUnit = deque.charCodeAt(0) - expect(leadUnit).toBeGreaterThanOrEqual(0xd800) - expect(leadUnit).toBeLessThanOrEqual(0xdbff) - }) -}) diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 2231fc2b29b..c8d699f36be 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, @@ -35,8 +36,7 @@ function getRelayZshWrapperSpec(): ZshStartupHookSpec { managedWslCli: false, agentTeamsPath: false, remoteCliBinDir: true, - codexHome: false, - codexLaunchPreflight: false + codexHome: false } } } @@ -74,7 +74,7 @@ fi [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} -${BASH_HISTFILE_RESTORE_BLOCK} +${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_initializing_wrapper=1 diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts index 41d3dd833e5..40eb6b62f32 100644 --- a/src/relay/relay-oversized-notification-survival.test.ts +++ b/src/relay/relay-oversized-notification-survival.test.ts @@ -205,21 +205,6 @@ describe('relay oversized notification survival', () => { } }) - it("publishAgentHookEnvelope does not mutate the caller's envelope", () => { - const sink: Buffer[] = [] - const dispatcher = createDispatcher(NODE21_HWM, () => {}, sink) - const envelope = buildMaximalHookEnvelope() - const before = structuredClone(envelope) - - try { - publishAgentHookEnvelope(dispatcher, envelope as unknown as AgentHookRelayEnvelope) - // The hook server replays this exact object after --connect, so shedding must never touch it. - expect(envelope).toEqual(before) - } finally { - dispatcher.dispose() - } - }) - it('reattach + replay does not re-kill', () => { const sink: Buffer[] = [] let killCloses = 0 diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index 0f0991e5c01..82b10e2fdf3 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' import { RelayDispatcher } from './dispatcher' @@ -200,13 +198,6 @@ describe('relay watcher writer admission', () => { }) describe('relay watcher overflow suppression key', () => { - it('keeps the source free of NUL bytes so git and grep still see text', () => { - const source = readFileSync( - fileURLToPath(new URL('./relay-watcher-event-emitter.ts', import.meta.url)) - ) - expect(source.includes(0)).toBe(false) - }) - it('scopes the outstanding marker per client as well as per root', () => { const primary = createRecordingSink(65536) const secondary = createRecordingSink(65536) diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 39ef1988fac..460804f7ed6 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -234,38 +234,6 @@ describe('Subprocess: Relay entry point', () => { expect(repaired.result).toMatchObject({ id: expect.stringMatching(/^pty2:[^:]+:2$/) }) }, 10_000) - it('responds to fs.stat over stdin/stdout', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') - - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('fs.stat', { filePath: path.join(tmpDir, 'test.txt') }) - const resp = await relay.waitForResponse(id) - - expect(resp.result).toBeDefined() - const result = resp.result as { size: number; type: string } - expect(result.type).toBe('file') - expect(result.size).toBe(5) - }, 10_000) - - it('responds to fs.readDir', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('fs.readDir', { dirPath: tmpDir }) - const resp = await relay.waitForResponse(id) - - const entries = resp.result as { name: string }[] - const names = entries.map((e) => e.name).sort() - expect(names).toEqual(['a.txt', 'b.txt']) - }, 10_000) - it('responds to fs.readFile and fs.writeFile', async () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 47c846040a7..6c52abb6518 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -18,7 +18,9 @@ import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/os import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' +import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' +import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -39,6 +41,7 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() + useTerminalViewerColorPublication() useAutomationDispatchEvents() // Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint. useGitStatusPolling({ enabled: workspaceSessionReady }) @@ -52,4 +55,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): usePrimarySelectionPaste(primarySelectionMiddleClickPaste) useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() + useCodexTerminalServerIsolationNotice() } diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index a2fd133ae32..7cee1762111 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -92,7 +92,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true }) ) - // Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data. + // Why: hidden-at-launch PTYs can query before any pane mounts; publish view attributes as soon as settings exist so every PTY owner answers from the composed theme. publishTerminalViewAttributesAtAppStart( useAppStore.getState().settings, getSystemPrefersDark() diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 19109c25eb2..168fb87f302 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -161,6 +161,7 @@ export function usePersistedUIWriter(): void { hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces, hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace, + explorerDisplayRootByWorktree: s.explorerDisplayRootByWorktree, showDotfilesByWorktree: s.showDotfilesByWorktree, filterRepoIds: s.filterRepoIds, // Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup diff --git a/src/renderer/src/app-shell/use-terminal-viewer-color-publication.test.tsx b/src/renderer/src/app-shell/use-terminal-viewer-color-publication.test.tsx new file mode 100644 index 00000000000..9f87990121a --- /dev/null +++ b/src/renderer/src/app-shell/use-terminal-viewer-color-publication.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { _resetTerminalViewAttributesPublisherForTest } from '../components/terminal-pane/terminal-view-attributes-publisher' +import { useAppStore } from '../store' + +const mocks = vi.hoisted(() => ({ + setColors: vi.fn(), + pushToAllHosts: vi.fn(), + publishTerminalViewAttributes: vi.fn() +})) + +vi.mock('../runtime/remote-runtime-terminal-color-push', () => ({ + remoteRuntimeTerminalColorPush: { + setColors: mocks.setColors, + pushToAllHosts: mocks.pushToAllHosts + } +})) + +import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' + +const initialState = useAppStore.getState() + +describe('useTerminalViewerColorPublication', () => { + beforeEach(() => { + _resetTerminalViewAttributesPublisherForTest() + Object.assign(window, { + api: { pty: { publishTerminalViewAttributes: mocks.publishTerminalViewAttributes } } + }) + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), theme: 'dark' } }) + }) + + afterEach(() => { + vi.clearAllMocks() + useAppStore.setState(initialState, true) + _resetTerminalViewAttributesPublisherForTest() + }) + + it('publishes a theme change with no terminal pane open, to main and to paired hosts', () => { + const { unmount } = renderHook(() => useTerminalViewerColorPublication()) + expect(mocks.setColors).toHaveBeenLastCalledWith({ + foreground: '#ffffff', + background: '#282c34' + }) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ settings: { ...settings, theme: 'light' } }) + }) + + expect(mocks.publishTerminalViewAttributes).toHaveBeenCalledTimes(2) + expect(mocks.setColors).toHaveBeenLastCalledWith({ + foreground: '#2e3434', + background: '#ffffff' + }) + unmount() + }) + + it('does not republish for an unrelated settings edit', () => { + const { unmount } = renderHook(() => useTerminalViewerColorPublication()) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ settings: { ...settings, editorAutoSave: !settings.editorAutoSave } }) + }) + + expect(mocks.publishTerminalViewAttributes).toHaveBeenCalledTimes(1) + unmount() + }) + + it('re-pushes the window colours to paired hosts when it gains focus', () => { + const { unmount } = renderHook(() => useTerminalViewerColorPublication()) + + window.dispatchEvent(new Event('focus')) + unmount() + window.dispatchEvent(new Event('focus')) + + expect(mocks.pushToAllHosts).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/app-shell/use-terminal-viewer-color-publication.ts b/src/renderer/src/app-shell/use-terminal-viewer-color-publication.ts new file mode 100644 index 00000000000..e0fe67ec42b --- /dev/null +++ b/src/renderer/src/app-shell/use-terminal-viewer-color-publication.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' +import { subscribeToPublishedTerminalViewColors } from '../components/terminal-pane/terminal-view-attributes-publisher' +import { remoteRuntimeTerminalColorPush } from '../runtime/remote-runtime-terminal-color-push' +import { useAppStore } from '../store' + +/** Reports this window's terminal theme to its own host and to every paired host. */ +export function useTerminalViewerColorPublication(): void { + const settings = useAppStore((s) => s.settings) + + // Why: panes publish only when they apply appearance, so with none open a theme change + // would never reach the hosts. The publisher dedupes, so an unrelated settings edit is free. + useEffect(() => { + if (!settings) { + return + } + const publish = (): void => { + publishTerminalViewAttributesAtAppStart(settings, getSystemPrefersDark()) + } + publish() + const mq = window.matchMedia('(prefers-color-scheme: dark)') + mq.addEventListener('change', publish) + return () => mq.removeEventListener('change', publish) + }, [settings]) + + useEffect(() => { + const unsubscribe = subscribeToPublishedTerminalViewColors( + remoteRuntimeTerminalColorPush.setColors + ) + window.addEventListener('focus', remoteRuntimeTerminalColorPush.pushToAllHosts) + return () => { + unsubscribe() + window.removeEventListener('focus', remoteRuntimeTerminalColorPush.pushToAllHosts) + } + }, []) +} diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index 314977a17aa..f5d404b4e10 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -6,16 +6,11 @@ // authority (main) = real updatePersistedUI/getPersistedUI merge // broadcast = controlled queue modeling the async ui:stateChanged IPC send // mobile client = mobile/src/worktree/workspace-view-settings mapping; the ui.set -// payload uses the shipping buildWorkspaceViewSettingsUpdate when -// exported, else the legacy whole-snapshot shape — the source-pin -// test asserts use-host-view-settings.ts matches whichever path is -// active, so the model stays tethered to shipping code. +// payload mirrors the shipping buildWorkspaceViewSettingsUpdate. // // Invariant: when two independently identified clients change DISJOINT workspace-view // fields concurrently or across stale-mirror windows, both changes survive and all // mirrors converge; no client may restore a stale sibling field. -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { StrictMode, act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -84,8 +79,7 @@ function createAuthority() { type Authority = ReturnType // Local model of mobile/src/worktree/workspace-view-settings.ts (the desktop test -// runner cannot transform Expo-configured sources). The source-pin test at the -// bottom fails if the shipping module stops matching this model. +// runner cannot transform Expo-configured sources). type MobileViewState = { groupMode: 'none' | 'workspaceStatus' | 'repo' | 'prStatus' sortMode: 'smart' | 'name' | 'recent' | 'repo' | 'manual' @@ -96,10 +90,6 @@ type MobileViewState = { collapsedGroups: string[] } -function mobileHasPatchOnlyBuilder(): boolean { - return readMobileViewSettingsSource().includes('export function buildWorkspaceViewSettingsUpdate') -} - /** Mirrors buildWorkspaceViewSettingsUpdate (candidate) — only touched fields. */ function patchOnlyUpdate( patch: Partial, @@ -127,18 +117,6 @@ function patchOnlyUpdate( return update } -/** Mirrors the pre-fix persistViewSettings payload — the full snapshot on every tap. */ -function legacyWholeSnapshotUpdate(next: MobileViewState): Partial { - return { - groupBy: next.groupMode === 'workspaceStatus' ? 'workspace-status' : 'repo', - sortBy: next.sortMode, - hideSleepingWorkspaces: next.hideSleeping, - hideDefaultBranchWorkspace: next.hideDefaultBranch, - filterRepoIds: next.filterRepoIds, - collapsedGroups: next.collapsedGroups - } -} - /** Model of the mobile host screen's view-settings client (persistViewSettings et al.). */ function createMobileClient(authority: Authority) { let view: MobileViewState = { @@ -171,28 +149,12 @@ function createMobileClient(authority: Authority) { /** A user tap: apply locally, then push through the shipping payload shape. */ tap(patch: Partial) { view = { ...view, ...patch } - const payload = mobileHasPatchOnlyBuilder() - ? patchOnlyUpdate(patch, view) - : legacyWholeSnapshotUpdate(view) + const payload = patchOnlyUpdate(patch, view) authority.set(omitPairingLocalUiFields(payload) as Partial) } } } -function readMobileViewSettingsHookSource(): string { - return readFileSync( - join(__dirname, '../../../../mobile/src/host-screen/use-host-view-settings.ts'), - 'utf-8' - ) -} - -function readMobileViewSettingsSource(): string { - return readFileSync( - join(__dirname, '../../../../mobile/src/worktree/workspace-view-settings.ts'), - 'utf-8' - ) -} - describe('workspace view preferences: cross-client persistence (STA-5781)', () => { let authority: Authority let store: StoreApi @@ -710,35 +672,4 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(authority.get().hideDefaultBranchWorkspace).toBe(false) expect(store.getState().hideDefaultBranchWorkspace).toBe(false) }) - - it('pins the modeled mobile ui.set payload to the shipping source', async () => { - const source = readMobileViewSettingsHookSource() - if (mobileHasPatchOnlyBuilder()) { - // Candidate: the persistence hook must push through the patch-only builder this model uses. - expect(source).toContain('buildWorkspaceViewSettingsUpdate(patch, next)') - const builderSource = readMobileViewSettingsSource() - for (const guard of [ - "if ('groupMode' in patch)", - "if ('sortMode' in patch)", - "if ('hideSleeping' in patch)", - "if ('hideDefaultBranch' in patch)", - "if ('filterRepoIds' in patch)", - "if ('collapsedGroups' in patch)" - ]) { - expect(builderSource).toContain(guard) - } - } else { - // Baseline: persistViewSettings pushes exactly this whole-snapshot payload. - for (const key of [ - 'groupBy: groupModeToDesktop(next.groupMode)', - 'sortBy: next.sortMode', - 'hideSleepingWorkspaces: next.hideSleeping', - 'hideDefaultBranchWorkspace: next.hideDefaultBranch', - 'filterRepoIds: next.filterRepoIds', - 'collapsedGroups: next.collapsedGroups' - ]) { - expect(source).toContain(key) - } - } - }) }) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts deleted file mode 100644 index fbaa404dd34..00000000000 --- a/src/renderer/src/app-startup-routing.test.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -function readSource(relativePath: string): string { - return readFileSync(join(process.cwd(), relativePath), 'utf8') -} - -const APP_PATH = 'src/renderer/src/App.tsx' -const STARTUP_HYDRATION_PATH = 'src/renderer/src/app-shell/use-app-startup-hydration.ts' -const DEGRADED_RECOVERY_PATH = 'src/renderer/src/startup/startup-degraded-recovery.ts' -const CHROME_LAYOUT_PATH = 'src/renderer/src/app-shell/use-app-chrome-layout.ts' -const SHELL_SERVICES_PATH = 'src/renderer/src/app-shell/use-app-shell-services.ts' -const BACKGROUND_SERVICES_PATH = 'src/renderer/src/app-shell/AppBackgroundServices.tsx' -const WORKSPACE_SHELL_PATH = 'src/renderer/src/app-shell/AppWorkspaceShell.tsx' -const ROOT_SURFACES_PATH = 'src/renderer/src/app-shell/AppRootSurfaces.tsx' -const LAZY_MODAL_MOUNTS_PATH = 'src/renderer/src/app-shell/use-lazy-modal-mounts.ts' -const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-persistence.ts' -const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts' -const BROWSER_GUEST_SESSION_PATH = - 'src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts' - -describe('renderer startup runtime routing', () => { - it('owns closed editor cleanup in the persistent app shell', () => { - expect(readSource(SHELL_SERVICES_PATH)).toContain('useClosedEditorTabCleanup()') - expect(readSource('src/renderer/src/components/editor/EditorPanel.tsx')).not.toContain( - 'useClosedEditorTabCleanup' - ) - }) - - it('routes packaged terminal restore through the daemon adoption gate', () => { - const source = readFileSync( - join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'), - 'utf8' - ) - const gateStart = source.indexOf('const startupActivationGateWorktreeIdsRef') - const gateEnd = source.indexOf('const startupResumeWorktreeIdsRef', gateStart) - const gateEffect = source.slice(gateStart, gateEnd) - - expect(gateStart).toBeGreaterThanOrEqual(0) - expect(gateEffect).toContain('void gateWorktreeAgentActivation(activeWorktreeId)') - expect(gateEffect).not.toContain('resumeSleepingAgentSessionsForWorktree') - }) - - it('hydrates persisted UI before local catalog and worktree hydration', () => { - const source = readSource(STARTUP_HYDRATION_PATH) - const startupBlockStart = source.indexOf('void (async () => {') - // Why: concurrent startup branches all settle before hydrate-session-stores. - const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") - const startupBlock = source.slice(startupBlockStart, startupBlockEnd) - - const indexInStartupBlock = (needle: string): number => { - const relativeIndex = startupBlock.indexOf(needle) - return relativeIndex === -1 ? -1 : startupBlockStart + relativeIndex - } - const settingsIndex = indexInStartupBlock( - 'actions.fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true })' - ) - const uiGetIndex = indexInStartupBlock("timeRendererStartupStep('ui-get'") - const hydrateUiIndex = indexInStartupBlock("timeRendererStartupSyncStep('hydrate-persisted-ui'") - const localReposIndex = indexInStartupBlock( - "actions.fetchReposForAllHosts({ remoteHosts: 'skip' })" - ) - const repoCatalogSettlementIndex = indexInStartupBlock( - "timeRendererStartupStep('repo-catalog-settlement'" - ) - const finalRepoCatalogSettlementIndex = indexInStartupBlock( - "timeRendererStartupStep('repo-catalog-final-settlement'" - ) - const localGroupsIndex = indexInStartupBlock( - "actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })" - ) - const localFoldersIndex = indexInStartupBlock( - "actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })" - ) - const sessionIndex = indexInStartupBlock("timeRendererStartupStep('session-get'") - const hydrationWorktreesIndex = source.indexOf( - "timeRendererStartupStep('fetch-hydration-worktrees'" - ) - // Why this barrier: worktree hydration can spawn host Git, so it must sit behind the - // shell-PATH + managed-WSL fence. On packaged Windows the window opens before - // shellPathReady resolves, so this really is the fence, not a formality. - const gitEnvironmentBarrierIndex = source.indexOf( - "timeRendererStartupStep('git-environment-barrier-await'", - sessionIndex - ) - const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()') - const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') - - expect(settingsIndex).toBeGreaterThanOrEqual(0) - expect(startupBlockEnd).toBeGreaterThan(startupBlockStart) - // Persisted UI hydrates before any local catalog/session/worktree read kicks off. - expect(settingsIndex).toBeLessThan(uiGetIndex) - expect(uiGetIndex).toBeLessThan(hydrateUiIndex) - expect(hydrateUiIndex).toBeLessThan(localReposIndex) - expect(localReposIndex).toBeLessThan(repoCatalogSettlementIndex) - expect(repoCatalogSettlementIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(finalRepoCatalogSettlementIndex) - expect(finalRepoCatalogSettlementIndex).toBeLessThan(startupBlockEnd) - // The local catalog chain stays internally ordered (folders merge against project groups). - expect(localReposIndex).toBeLessThan(localGroupsIndex) - expect(localGroupsIndex).toBeLessThan(localFoldersIndex) - expect(localReposIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex) - expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex) - expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain( - 'window.api.app.awaitGitEnvironmentStartupBarrier()' - ) - const hydrationWorktreeBlock = source.slice( - hydrationWorktreesIndex, - source.indexOf('await keybindingsPromise') - ) - expect(hydrationWorktreeBlock).toContain( - 'mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY' - ) - expect(hydrationWorktreeBlock).toContain('executionHostId: getRepoExecutionHostId(repo)') - // Why: the pre-hydration fetch must include SSH repos (only runtime-owned repos are - // excluded); gating on local-only drops SSH tab/editor/browser chrome at hydration. - const hydrationFilterBlock = source.slice( - source.indexOf('const hydrationRepos'), - hydrationWorktreesIndex - ) - expect(hydrationFilterBlock).toContain( - "parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'" - ) - expect(hydrationFilterBlock).not.toContain('=== LOCAL_EXECUTION_HOST_ID') - expect(fullWorktreesIndex).toBeGreaterThan( - source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'") - ) - const ownerDefaultsIndex = source.indexOf( - 'actions.awaitOwnerWorktreeVisibilityDefaultsHydration()' - ) - const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'") - expect(ownerDefaultsIndex).toBeGreaterThan( - source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'") - ) - expect(ownerDefaultsIndex).toBeLessThan(remoteCatalogIndex) - // Why: the deferred full scan must be followed by a re-prune so deleted-worktree visit - // timestamps for non-session repos are dropped once every repo is authoritative. - expect( - source.indexOf('actions.pruneLastVisitedTimestamps()', fullWorktreesIndex) - ).toBeGreaterThan(fullWorktreesIndex) - expect(lineageIndex).toBe(-1) - - // The catalog and selective hydration chains overlap, but both settle before recovery or hydration. - const joinStart = indexInStartupBlock('await Promise.allSettled([') - expect(joinStart).toBeGreaterThan(hydrateUiIndex) - expect(joinStart).toBeLessThan(finalRepoCatalogSettlementIndex) - const joinBlock = source.slice(joinStart, startupBlockEnd) - expect(joinBlock).toContain('hydrationSessionChain') - expect(joinBlock).toContain('localCatalogChain') - expect(startupBlock).not.toContain('await Promise.all([') - expect(startupBlock).not.toContain("actions.fetchAllWorktrees({ hydrationPurge: 'defer' })") - }) - - it('refreshes remote catalogs after startup hydration succeeds', () => { - const source = readSource(STARTUP_HYDRATION_PATH) - const hydrationDoneIndex = source.indexOf( - "logRendererStartupDiagnostic('startup-hydration-done'" - ) - const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'") - const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'") - const remoteCatalogFailureIndex = source.indexOf( - "console.warn('Remote startup catalog refresh failed:'" - ) - const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()') - const startupRefreshCompletedIndex = source.indexOf('startupWorktreeRefreshCompleted: true') - - expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0) - expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex) - expect(remoteCatalogIndex).toBeLessThan(remoteCatalogFailureIndex) - // Why: a project-group/folder catalog failure must not suppress the independent full worktree scan. - expect(remoteCatalogFailureIndex).toBeLessThan(remoteWorktreeIndex) - expect(remoteWorktreeIndex).toBeLessThan(lineageIndex) - expect(lineageIndex).toBeLessThan(startupRefreshCompletedIndex) - expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( - 'actions.fetchReposForAllHosts()' - ) - - const degradedSource = readSource(DEGRADED_RECOVERY_PATH) - const startupFailureIndex = degradedSource.indexOf( - '[startup] Workspace session hydration failed; leaving disk state untouched:' - ) - expect(startupFailureIndex).toBeGreaterThanOrEqual(0) - expect( - degradedSource.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex) - ).toBeGreaterThan(startupFailureIndex) - expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( - 'actions.fetchProjectGroupsForAllHosts()' - ) - expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( - 'actions.fetchFolderWorkspacesForAllHosts()' - ) - }) - - it('waits for first-window startup services before terminal reconnect', () => { - const source = readSource(STARTUP_HYDRATION_PATH) - // Why this step: `app:prepareTerminalStartupRestoration` awaits - // firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it - // does anything else, so it is the renderer-side position of that fence. - // `desktop-startup-ordering.test.ts` pins the main-side await itself. - const servicesIndex = source.indexOf( - "timeRendererStartupStep('prepare-terminal-startup-restoration'" - ) - const preReconnectRecoveryIndex = source.indexOf( - "timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'" - ) - const capabilityRefreshIndex = source.indexOf( - "timeRendererStartupStep('terminal-provider-snapshot-capabilities'" - ) - const reconnectIndex = source.indexOf("timeRendererStartupStep('reconnect-terminals'") - const postReconnectRecoveryIndex = source.indexOf( - "timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect'" - ) - - expect(servicesIndex).toBeGreaterThanOrEqual(0) - expect(source.slice(servicesIndex)).toContain( - 'window.api.app.prepareTerminalStartupRestoration()' - ) - expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex) - expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex) - expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) - expect(postReconnectRecoveryIndex).toBeGreaterThan(reconnectIndex) - }) - - it('refreshes terminal snapshot capability before degraded reconnect', () => { - const source = readSource(DEGRADED_RECOVERY_PATH) - const degradedStart = source.indexOf( - '[startup] Workspace session hydration failed; leaving disk state untouched:' - ) - const servicesIndex = source.indexOf( - 'await window.api.app.awaitFirstWindowStartupServices()', - degradedStart - ) - const recoveryIndex = source.indexOf( - 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', - servicesIndex - ) - const capabilityRefreshIndex = source.indexOf( - 'refreshTerminalProviderSnapshotCapabilities(', - recoveryIndex - ) - const reconnectIndex = source.indexOf('reconnectPersistedTerminals(abortSignal)', recoveryIndex) - - expect(degradedStart).toBeGreaterThanOrEqual(0) - expect(servicesIndex).toBeGreaterThan(degradedStart) - expect(recoveryIndex).toBeGreaterThan(servicesIndex) - expect(capabilityRefreshIndex).toBeGreaterThan(recoveryIndex) - expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) - }) - - it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { - const source = readSource( - 'src/renderer/src/components/automations/use-automations-page-refresh.ts' - ) - const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef') - const fullRefreshEffect = source.slice( - fullRefreshStart, - source.indexOf('void refresh()', fullRefreshStart) - ) - - expect(fullRefreshEffect).toContain('if (!startupWorktreeRefreshCompleted)') - expect(fullRefreshEffect).toContain('mountedBeforeStartupWorktreeRefreshRef.current') - expect(fullRefreshEffect).toContain('void fetchAllWorktrees()') - }) - - it('does not eagerly import the floating terminal panel on startup', () => { - const shellSource = readSource(WORKSPACE_SHELL_PATH) - const surfacesSource = readSource(ROOT_SURFACES_PATH) - - expect(shellSource).toContain( - "import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'" - ) - expect(surfacesSource).toContain( - "import('../components/floating-terminal/FloatingTerminalPanel').then" - ) - for (const source of [shellSource, surfacesSource]) { - expect(source).not.toContain("from '../components/floating-terminal/FloatingTerminalPanel'") - } - }) - - it('does not eagerly import idle optional overlay surfaces on startup', () => { - const source = readSource(ROOT_SURFACES_PATH) - - expect(source).toContain("import('../components/UpdateCard').then") - expect(source).toContain("import('../components/contextual-tours/ContextualTourOverlay').then") - expect(source).toContain("import('../components/setup-guide/SetupGuideTelemetryObserver').then") - expect(source).not.toContain("from '../components/UpdateCard'") - expect(source).not.toContain("from '../components/contextual-tours/ContextualTourOverlay'") - expect(source).not.toContain("from '../components/setup-guide/SetupGuideTelemetryObserver'") - expect(source).toContain('const shouldMountSetupGuideTelemetryObserver = persistedUIReady') - expect(source).not.toContain( - "const shouldMountSetupGuideTelemetryObserver = persistedUIReady && activeModal === 'setup-guide'" - ) - }) - - it('keeps crash-report listeners eager while lazy-loading the dialog surface', () => { - const surfacesSource = readSource(ROOT_SURFACES_PATH) - const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx') - - expect(surfacesSource).toContain( - "import { CrashReportDialog } from '../components/crash-report/CrashReportDialog'" - ) - expect(surfacesSource).not.toContain( - "from '../components/crash-report/CrashReportDialogSurface'" - ) - expect(hostSource).toContain("import('./CrashReportDialogSurface').then") - expect(hostSource).toContain('window.api.crashReports.getLatestPending()') - expect(hostSource).toContain('window.api.ui.onOpenCrashReport') - expect(hostSource).toContain('REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT') - expect(hostSource).toContain('if (!open) {') - expect(hostSource).not.toContain('if (!open && !loading)') - }) - - it('clears stale crash-report state before opening the lazy manual report surface', () => { - const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx') - const manualOpenStart = hostSource.indexOf('return window.api.ui.onOpenCrashReport(() => {') - const manualOpenEnd = hostSource.indexOf(' }, [loadCrashReport])', manualOpenStart) - const manualOpenBlock = hostSource.slice(manualOpenStart, manualOpenEnd) - - expect(manualOpenBlock.indexOf('setReport(null)')).toBeGreaterThanOrEqual(0) - expect(manualOpenBlock.indexOf('setReport(null)')).toBeLessThan( - manualOpenBlock.indexOf('setOpen(true)') - ) - expect(manualOpenBlock.indexOf('setReport(null)')).toBeLessThan( - manualOpenBlock.indexOf('loadCrashReport(false)') - ) - }) - - it('loads dictation only when voice is enabled or a session is active', () => { - const source = readSource(ROOT_SURFACES_PATH) - - expect(source).toContain("import('../components/dictation/DictationController').then") - expect(source).not.toContain("from '../components/dictation/DictationController'") - expect(source).toContain('useAppStore(selectAppRootSurfaceVoiceEnabled)') - expect(source).toContain("voiceEnabled || dictationState !== 'idle'") - expect(source).toContain('shouldMountDictationController ?') - }) - - it('loads the SSH passphrase dialog only when a credential request is queued', () => { - const source = readSource(ROOT_SURFACES_PATH) - - expect(source).toContain("import('../components/settings/SshPassphraseDialog').then") - expect(source).not.toContain("from '../components/settings/SshPassphraseDialog'") - expect(source).toContain('s.sshCredentialQueue.length > 0') - expect(source).toContain('hasSshCredentialRequest ?') - }) - - it('defers background polling until the workspace session is ready', () => { - expect(readSource(SHELL_SERVICES_PATH)).toContain( - 'useGitStatusPolling({ enabled: workspaceSessionReady })' - ) - expect(readSource(BACKGROUND_SERVICES_PATH)).toContain( - '' - ) - }) - - it('prefetches terminal snapshot capabilities before reconnect unlocks cold activation', () => { - const source = readSource(STARTUP_HYDRATION_PATH) - const capabilityIndex = source.indexOf( - "timeRendererStartupStep('terminal-provider-snapshot-capabilities'" - ) - const reconnectIndex = source.indexOf("timeRendererStartupStep('reconnect-terminals'") - - expect(capabilityIndex).toBeGreaterThanOrEqual(0) - expect(reconnectIndex).toBeGreaterThan(capabilityIndex) - }) - - it('skips startup structured tab projection while the host setting is off', () => { - const source = readSource(STARTUP_HYDRATION_PATH) - const projectIndex = source.indexOf("timeRendererStartupStep('project-structured-session-tabs'") - - expect(projectIndex).toBeGreaterThanOrEqual(0) - expect(source.slice(projectIndex - 180, projectIndex)).toContain( - 'settings?.experimentalStructuredNativeChat === true' - ) - }) - - it('probes local runtime capabilities before any startup gate can hold the answer back', () => { - const source = readSource(STARTUP_HYDRATION_PATH) - const probeIndex = source.indexOf('void ensureLocalRuntimeCapabilities()') - const chainStart = source.indexOf('void (async () => {') - const effectStart = source.lastIndexOf('useEffect(() => {', probeIndex) - - expect(probeIndex).toBeGreaterThanOrEqual(0) - // Why pinned here: the structured-session-tabs sync is the cache's only other writer and it - // waits for workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. - // Every resolveAgentLaunchRoute reader — including the three that cannot await — reads an - // unanswered cache as "unsupported", so a create in that window degrades to a bare - // terminal (#19154). The probe must therefore start before the chain and outside its gates. - expect(probeIndex).toBeLessThan(chainStart) - expect(probeIndex).toBeLessThan(source.indexOf('await ', effectStart)) - expect(source.slice(effectStart, probeIndex)).not.toContain('if (') - expect(source.slice(effectStart, probeIndex)).not.toContain('experimentalStructuredNativeChat') - }) - - it('orders packaged restoration before adoption, projection, and default creation', () => { - // Why this file: the startup sequence moved out of App.tsx into the hydration hook; - // the ordering it asserts is unchanged, only the module that now spells it out. - const appSource = readFileSync( - join(process.cwd(), 'src/renderer/src/app-shell/use-app-startup-hydration.ts'), - 'utf8' - ) - const terminalSource = readFileSync( - join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'), - 'utf8' - ) - const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") - const prepareIndex = appSource.indexOf( - "timeRendererStartupStep('prepare-terminal-startup-restoration'" - ) - const reconnectIndex = appSource.indexOf("timeRendererStartupStep('reconnect-terminals'") - const projectIndex = appSource.indexOf( - "timeRendererStartupStep('project-structured-session-tabs'" - ) - const readyIndex = appSource.indexOf('actions.setTerminalStartupRestorationReady(true)') - const gateStart = terminalSource.indexOf('const startupActivationGateWorktreeIdsRef') - const gateEnd = terminalSource.indexOf('const startupResumeWorktreeIdsRef', gateStart) - const gateBlock = terminalSource.slice(gateStart, gateEnd) - const gateIndex = gateBlock.indexOf('gateWorktreeAgentActivation(activeWorktreeId)') - const createIndex = gateBlock.indexOf( - 'createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })' - ) - - expect(hydrateIndex).toBeGreaterThanOrEqual(0) - expect(hydrateIndex).toBeLessThan(prepareIndex) - expect(prepareIndex).toBeLessThan(reconnectIndex) - expect(reconnectIndex).toBeLessThan(projectIndex) - expect(projectIndex).toBeLessThan(readyIndex) - expect(gateBlock).toContain('terminalStartupRestorationReady') - expect(gateBlock).not.toContain('hydrationSucceeded') - expect(gateIndex).toBeGreaterThanOrEqual(0) - expect(gateIndex).toBeLessThan(createIndex) - expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome !== 'empty'") - }) - - it('does not load the terminal workbench on the no-workspace landing path', () => { - const shellSource = readSource(WORKSPACE_SHELL_PATH) - const layoutSource = readSource(CHROME_LAYOUT_PATH) - - expect(shellSource).toContain("const Terminal = lazy(() => import('../components/Terminal'))") - expect(shellSource).not.toContain("from '../components/Terminal'") - expect(layoutSource).toContain( - 'const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested' - ) - // Why pin the latch: once the workbench has mounted it must stay mounted, so hidden - // terminal/browser/editor panes survive activeWorktreeId briefly going null. - expect(layoutSource).toContain( - 'const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)' - ) - expect(layoutSource).toContain('setHasMountedTerminalWorkbench(true)') - expect(layoutSource).toContain( - 'const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench' - ) - expect(shellSource).toContain('shouldMountTerminalWorkbench ?') - }) - - it('keeps the new-workspace composer eager because it is a critical create surface', () => { - const source = readSource(ROOT_SURFACES_PATH) - const lazyModalSource = readSource('src/renderer/src/lazy-modal-mount-state.ts') - - expect(source).toContain( - "import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal'" - ) - expect(source).not.toContain("import('../components/NewWorkspaceComposerModal')") - expect(source).toContain("activeModal === 'new-workspace-composer'") - expect(lazyModalSource).not.toContain("'new-workspace-composer'") - }) - - it('does not eagerly import inactive sidebar dialog flows on startup', () => { - const surfacesSource = readSource(ROOT_SURFACES_PATH) - const mountSource = readSource(LAZY_MODAL_MOUNTS_PATH) - const sidebarSource = readSource('src/renderer/src/components/sidebar/index.tsx') - - expect(surfacesSource).toContain("lazy(() => import('../components/sidebar/AddRepoDialog'))") - expect(surfacesSource).toContain( - "lazy(() => import('../components/sidebar/NonGitFolderDialog'))" - ) - expect(surfacesSource).toContain("import('../components/sidebar/AddProjectFromFolderDialog')") - expect(surfacesSource).toContain( - "lazy(() => import('../components/sidebar/ProjectAddedDialog'))" - ) - expect(surfacesSource).toContain("activeModal === 'add-repo'") - expect(surfacesSource).toContain("activeModal === 'confirm-non-git-folder'") - expect(surfacesSource).toContain("activeModal === 'confirm-add-project-from-folder'") - expect(surfacesSource).toContain("activeModal === 'project-added'") - expect(surfacesSource).toContain('shouldMountAddRepoDialog ? (') - expect(surfacesSource).toContain('boundaryId="modal.add-repo"') - expect(surfacesSource).toContain('boundaryId="modal.confirm-non-git-folder"') - expect(surfacesSource).toContain('boundaryId="modal.confirm-add-project-from-folder"') - expect(surfacesSource).toContain('boundaryId="modal.project-added"') - expect(mountSource).toContain('setTimeout(() =>') - expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))") - expect(sidebarSource).not.toContain("from './AddRepoDialog'") - expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))") - expect(sidebarSource).not.toContain("React.lazy(() => import('./NonGitFolderDialog'))") - expect(sidebarSource).not.toContain("React.lazy(() => import('./AddProjectFromFolderDialog'))") - expect(sidebarSource).not.toContain("React.lazy(() => import('./ProjectAddedDialog'))") - expect(sidebarSource).not.toContain('shouldMountAddRepoDialog ? : null') - expect(sidebarSource).not.toContain( - "activeModal === 'confirm-non-git-folder' ? : null" - ) - expect(sidebarSource).not.toContain( - "activeModal === 'confirm-add-project-from-folder' ? : null" - ) - expect(sidebarSource).not.toContain( - "activeModal === 'project-added' ? : null" - ) - expect(sidebarSource).toContain("activeModal === 'edit-meta' ? : null") - expect(sidebarSource).toContain( - "activeModal === 'confirm-remove-folder' ? : null" - ) - }) - - it('loads Linear agent setup implementation only after the prompt opens it', () => { - const source = readSource('src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx') - - expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')") - expect(source).not.toContain("from './LinearAgentSkillSetupDialog'") - expect(source).toContain('const setupDialog = setupDialogOpen ? (') - expect(source).toContain('') - }) - - it('does not eagerly import optional status-bar segments on startup', () => { - const source = readSource('src/renderer/src/components/status-bar/StatusBarSurface.tsx') - - expect(source).toContain("import('./ResourceUsageStatusSegment').then") - expect(source).toContain("import('./PortsStatusSegment').then") - expect(source).toContain("import('./SshStatusSegment').then") - expect(source).toContain("import('./PetStatusSegment').then") - expect(source).not.toContain("from './ResourceUsageStatusSegment'") - expect(source).not.toContain("from './PortsStatusSegment'") - expect(source).not.toContain("from './SshStatusSegment'") - expect(source).not.toContain("from './PetStatusSegment'") - }) - - it('does not eagerly import the status bar shell on startup', () => { - const source = readSource(ROOT_SURFACES_PATH) - - expect(source).toContain("import('../components/status-bar/StatusBar').then") - expect(source).not.toContain("from '../components/status-bar/StatusBar'") - expect(source).toContain('statusBarVisible ? (') - expect(source).toContain('h-6 min-h-[24px] shrink-0 border-t border-border') - }) - - it('keeps activeView off the 150ms debounced UI writer hot path (#9002)', () => { - const source = readSource(PERSISTED_UI_WRITER_PATH) - const writerStart = source.indexOf('const timer = window.setTimeout(() => {') - const writerEnd = source.indexOf('}, 150)', writerStart) - const writerBlock = source.slice(writerStart, writerEnd) - - expect(writerStart).toBeGreaterThanOrEqual(0) - expect(writerEnd).toBeGreaterThan(writerStart) - // Why: this field riding the writer's payload (#8265) is exactly the - // #9002 regression — every switch scheduled a full durable-state save. It - // must persist through its narrow preference or unload path instead. Matched as - // a standalone object-literal property (not the surrounding prose, which - // legitimately references the field name) so the assertion is precise. - expect(writerBlock).not.toMatch(/^\s*activeView,\s*$/m) - - const depsStart = source.indexOf('}, [', writerEnd) - const depsEnd = source.indexOf('])', depsStart) - const depsBlock = source.slice(depsStart, depsEnd) - expect(depsBlock).not.toMatch(/^\s*activeView,?\s*$/m) - }) - - it('persists activeView through its narrow preference on every switch (#9002)', () => { - const source = readSource(PERSISTED_UI_WRITER_PATH) - - const preferenceEffect = [ - '// Why (#9002): activeView has its own tiny profile preference', - 'void window.api.ui.set({ activeView })', - '}, [activeView, persistedUIReady])' - ] - for (const marker of preferenceEffect) { - expect(source).toContain(marker) - } - expect(source).not.toContain('createActiveViewIdleFlush') - expect(source).not.toContain("window.addEventListener('blur', handleBlur)") - }) - - it('arms the OSC 52 default-on notice behind a statically mounted Toaster (#10567)', () => { - // Why pin the call site: the hook is the only caller, so deleting this line silences - // the migration notice on desktop with every unit suite still green. - expect(readSource(SHELL_SERVICES_PATH)).toContain( - 'useOsc52ClipboardDefaultOnNotice(persistedUIReady)' - ) - // Why pin the static import and the unconditional mount: sonner drops a toast enqueued - // before any Toaster subscribes, and never replays it — a lazy Toaster would burn the - // profile's one notice with its callbacks never firing, so it could never re-arm. - const appSource = readSource(APP_PATH) - expect(appSource).toContain("import { Toaster } from '@/components/ui/sonner'") - expect(appSource).not.toContain("import('@/components/ui/sonner')") - expect(appSource).toContain(' { - expect(readSource(SHELL_SERVICES_PATH)).toContain('useBrowserIdentityMigrationNotice()') - expect(readSource(BROWSER_GUEST_SESSION_PATH)).not.toContain( - 'showPendingBrowserUserAgentMigrationNotice' - ) - }) - - it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => { - const source = readSource(SESSION_PERSISTENCE_PATH) - const checkpointStart = source.indexOf( - 'const shutdownCheckpointPersist = createShutdownCheckpointPersist({' - ) - const checkpointEnd = source.indexOf( - 'const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)', - checkpointStart - ) - expect(checkpointStart).toBeGreaterThanOrEqual(0) - expect(checkpointEnd).toBeGreaterThan(checkpointStart) - const checkpointBlock = source.slice(checkpointStart, checkpointEnd) - - expect(checkpointBlock).toContain( - 'const shutdownCheckpointPersist = createShutdownCheckpointPersist({' - ) - expect(checkpointBlock).toContain( - 'buildWorkspaceSessionHostSnapshots(\n buildWorkspaceSessionPayload(freshState),\n freshState\n )' - ) - expect(checkpointBlock).toContain('buildUiPatch: () => buildActiveViewUnloadPatch(') - // Why pin the exact gate: the degrade tiers must arm only for intentional - // restarts and app-level closes, never for arbitrary unloads. - expect(checkpointBlock).toContain( - 'isIntentionalAppRestartInProgress() || isWindowCloseCheckpointInProgress()' - ) - expect(checkpointBlock).toContain( - 'useAppStore.getState().openFiles.some((file) => file.isDirty)' - ) - expect(checkpointBlock).toContain( - 'stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args)' - ) - expect(checkpointBlock).toContain('shutdownCheckpointPersist.run') - expect(checkpointBlock).toContain('shutdownCheckpointPersist.abandonAttempt') - expect(source).toContain( - 'window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.abandonAttempt)' - ) - expect(source).toContain( - 'ORCA_RENDERER_SHUTDOWN_CHECKPOINT_ABORTED_EVENT,\n shutdownCheckpoint.abortAfterCheckpointFailure' - ) - expect(source).toContain( - 'window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.abandonAttempt)' - ) - expect(source).toContain("window.addEventListener('beforeunload', persistBeforeUnload)") - expect(source.match(/window\.addEventListener\('beforeunload'/g) ?? []).toHaveLength(1) - expect(source).not.toContain('window.api.ui.setSync') - }) -}) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 2d999cc48db..9f1b84d873e 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -733,6 +733,17 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent); } +/* Why: tabs only draw a right border, so a tab docked at the end has no seam against the tab it covers. */ +.terminal-tab-strip[data-active-tab-docked='end'] > [data-active-tab-dock]::before { + content: ''; + position: absolute; + inset-block: 0; + left: 0; + width: 1px; + background: var(--border); + z-index: 30; +} + /* Tab activity affordance (amber wash) is rendered as a React DOM child in SortableTab.tsx rather than via ::after here, so the drop-indicator ::before/::after pseudo-elements stay free for drag-and-drop feedback. See diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 33642542d79..a767a61ccf4 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -47,15 +47,9 @@ describe('AgentStateDot', () => { it('renders working as a yellow spinner', () => { const markup = renderMarkup('working') - expect(markup).toContain('border-yellow-500') - expect(markup).toContain('border-t-transparent') - // Why: rotation must come from the compositor-driven CSS animation, not a - // JS clock writing per-element styles on the input thread (STA-3328). - expect(markup).toContain('agent-working-spinner') + // The spinner's own classes and animation contract belong to AgentWorkingSpinner.test.tsx; + // this pins only that 'working' reaches for it. expect(markup).toContain('data-agent-spinner') - // Why: under reduced motion the top border is filled so the static ring - // reads as a complete marker, not a broken partial spinner (#9515). - expect(markup).toContain('motion-reduce:border-t-yellow-500') }) it('renders monitoring as a static yellow heartbeat glyph', () => { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 11f2bddc122..1a139b18d26 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -12,6 +12,7 @@ import { TooltipProvider } from './ui/tooltip' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' import { consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { @@ -271,6 +272,103 @@ it('never re-offers a resumed chat when the status entry reopens the dialog', as expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) }) +// The resume outlives the dialog, as a skill update does: the status bar carries it while in flight. +it('closes on Resume and shows the resume in the status bar until the host answers', async () => { + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: offered }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(button('Resuming 2 chats. Click to open details.').textContent).toBe('Resuming 2 chats') + // Counted once, as in flight, not also as still to resume. + expect(document.body.textContent).not.toContain('chats to resume') + + // Reopening mid-run shows the run, without a re-read that could race the host's answer. + const reads = rpc.mock.calls.length + await act(async () => button('Resuming 2 chats').click()) + expect(rpc.mock.calls.length).toBe(reads) + expect(button('Resuming…').disabled).toBe(true) + + await act(async () => + continued.resolve({ + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [] + }) + ) + expect(document.body.textContent).not.toContain('Resuming') + expect(document.querySelector('[role="dialog"]')).toBeNull() + // Nothing is left to show, so the reopen request is retired rather than left to latch. + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(toast).toHaveBeenCalledWith('Resumed 2 chats and asked them to continue') +}) + +// A dialog the user reopened mid-run is theirs: the run's answer must not close it over a chat +// they left out of the resume and can now act on. +it('keeps a dialog reopened mid-resume open over the chats still offered', async () => { + const third = { ...offered[1]!, sessionId: 'c', latestPrompt: 'Prompt c' } + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: [...offered, third] }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => checkbox(2).click()) + await act(async () => button('Resume 2 chats').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => button('1 chat to resume').click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + // Mid-run the ticks say what is running, so the chat left out reads as left out. + const rowC = () => document.querySelector('[role="checkbox"][aria-label*="Prompt c"]') + expect(checkbox(0).getAttribute('data-state')).toBe('checked') + expect(checkbox(1).getAttribute('data-state')).toBe('checked') + expect(rowC()?.getAttribute('data-state')).toBe('unchecked') + + await act(async () => + continued.resolve({ + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [third] + }) + ) + expect(rowC()?.getAttribute('data-state')).toBe('checked') + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Prompt c') + expect(dialog?.textContent).not.toContain('Prompt a') + // The run is over, so the chat left out is actionable again, and this opening ticks it afresh. + expect(button('Dismiss all').disabled).toBe(false) + expect(checkbox(0).getAttribute('data-state')).toBe('checked') + expect(button('Resume 1 chat').disabled).toBe(false) +}) + +it('starts each opening from the default ticks, not the ones left at the last close', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + await act(async () => checkbox(1).click()) + expect(button('Resume 1 chat')).toBeTruthy() + await act(async () => button('Close').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + + await act(async () => requestNativeChatResumeOnRestartDialog()) + expect(checkbox(1).getAttribute('data-state')).toBe('checked') + expect(button('Resume 2 chats').disabled).toBe(false) +}) + // Resuming spends the host's claims, so the offer has to shrink with it. A count left standing over // chats the host already handed back sends the user to a status entry that re-reads, finds nothing, // and does nothing. @@ -336,6 +434,35 @@ it('resumes and continues once when the launch begins opted in', async () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) +// No dialog to watch, so the status bar is the only sign an automatic resume is running. +it('shows an opted-in launch resume in the status bar while it runs', async () => { + useAppStore.setState({ + settings: { + ...getDefaultSettings(''), + experimentalStructuredNativeChat: true, + nativeChatResumeWorkOnRestart: true + } + }) + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: offered }) + : continued.promise + ) + await mount() + expect(button('Resuming 2 chats').getAttribute('aria-label')).toBe( + 'Resuming 2 chats. Click to open details.' + ) + await act(async () => + continued.resolve({ + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [] + }) + ) + expect(document.body.textContent).not.toContain('Resuming') +}) + // An opted-in launch reports the chats the host would not take, exactly as the button does. it('reports refused and newly ineligible chats on an opted-in launch', async () => { useAppStore.setState({ @@ -507,7 +634,7 @@ function dialogControls(): (string | null)[] { // The old toast said "1 chat could not be continued" and vanished. The chat now stays in the same // dialog — same title, checkboxes and footer — with its row saying what went wrong and what to do. -it('keeps a chat the resume could not carry on in the same dialog, with what to do', async () => { +it('lists a chat the resume could not carry on when the dialog reopens, with what to do', async () => { let remaining: unknown[] = [] rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' @@ -525,6 +652,9 @@ it('keeps a chat the resume could not carry on in the same dialog, with what to ) await mount() await act(async () => button('Resume 2 chats').click()) + // Resume hands off to the status bar; its failure entry (or the toast's Show) reopens the list. + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => requestNativeChatResumeOnRestartDialog()) const dialog = document.querySelector('[role="dialog"]') expect(dialog).not.toBeNull() // Unchanged chrome: the title, the preference box, and the two footer actions. @@ -594,6 +724,68 @@ it.each(['footer', 'row'] as const)( } ) +// A row action acts on its row, as Dismiss does: a retry that clears the last failure must not close +// the dialog over a chat still offered. +it('keeps the dialog open over the chats still offered after a row Retry succeeds', async () => { + let failed = [failure('b', 'agent_session_conflict')] + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed } + : ((failed = []), + { + resumed: [{ sessionId: 'b', outcome: 'resumed' }], + continued: [{ sessionId: 'b', outcome: 'continued' }], + sessions: [offered[0]], + failed + }) + ) + await mount() + await act(async () => button('Retry').click()) + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Prompt a') + expect(dialog?.textContent).not.toContain('Prompt b') +}) + +// A dialog closed and reopened mid-retry is the user's again: the retry settling must not close it. +it('keeps a dialog reopened mid-retry open when the retry settles', async () => { + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ + sessions: [offered[0]], + failed: [failure('b', 'agent_session_conflict')] + }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Retry').click()) + await act(async () => button('Close').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => button('1 chat to resume').click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + // Only the retried row is running, so only it reads as ticked until the retry settles. + const tick = (prompt: string) => + document.querySelector(`[role="checkbox"][aria-label*="${prompt}"]`)?.getAttribute('data-state') + expect(tick('Prompt a')).toBe('unchecked') + expect(tick('Prompt b')).toBe('checked') + + await act(async () => + continued.resolve({ + resumed: [{ sessionId: 'b', outcome: 'resumed' }], + continued: [{ sessionId: 'b', outcome: 'continued' }], + sessions: [offered[0]], + failed: [] + }) + ) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Prompt a') + expect(tick('Prompt a')).toBe('checked') +}) + // The user's case: the only row is a failure the host says a retry cannot fix. Ticking it could // only fail again, so the row's own action is the way on and the box cannot be ticked. it('keeps a failure the host marks unretryable out of Resume, even after a tick', async () => { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 5cce3f2aa1a..315a19e3170 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -28,8 +28,8 @@ import { import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, - getNativeChatRestartOffer, - useNativeChatRestartOffer + useNativeChatRestartOffer, + useNativeChatRestartResuming } from './native-chat-resume-on-restart-store' /** @@ -42,9 +42,13 @@ import { * The "don't ask again" box removes the PROMPT, never a safety check — an opted-in launch calls * the same RPC, which re-derives the same predicate and staggers the same way. * + * Resume closes the dialog at once and the status-bar entry carries the run, then any chat it could + * not carry on. The run lives in the store, as a skill update's does, so the dialog is one view of it. + * * A chat an earlier resume could not carry on is listed too, as the same row plus what went wrong * and what to do; selecting it and resuming is a retry, unless the host says a retry cannot run. - * The dialog stays open while any remain, so the outcome is never left to a toast. + * Row actions (Retry, Dismiss) act on their row and leave the dialog open. It closes only on the + * user's own way out, or once the host confirms nothing is left; a resume settling never closes it. * * Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss all is * the explicit path that deletes the durable records. @@ -78,11 +82,22 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { ) const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) - const [busy, setBusy] = useState(false) + // The store's: the resume outlives this dialog, which can close or reopen mid-run. + const resuming = useNativeChatRestartResuming() + const busy = resuming.length > 0 /** The user's own ticks and unticks, over each row's default. Tracked as OVERRIDES rather than a * selection because the list is the host's and arrives — and shrinks — under an open dialog; a * stored selection would need seeding from an effect every time it changed. */ const [overrides, setOverrides] = useState>(() => new Map()) + // Each opening starts from the rows' defaults. This component never unmounts, so an untick made + // before a close would otherwise greet a reopen, e.g. as "Resume 0 chats" over what a run left. + const [openedWith, setOpenedWith] = useState(open) + if (openedWith !== open) { + setOpenedWith(open) + if (open) { + setOverrides(new Map()) + } + } /** Derived from the host's own list, so an action can never name a chat it did not list. */ const chosen = useMemo( () => @@ -99,6 +114,8 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { [rows, overrides, failureBySession] ) const selected = useMemo(() => new Set(chosen), [chosen]) + // Mid-run the ticks show what is running; this opening's own ticks may name chats left out of it. + const ticked = useMemo(() => (busy ? new Set(resuming) : selected), [busy, resuming, selected]) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { setOverrides((current) => new Map(current).set(sessionId, checked)) @@ -111,19 +128,11 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { } }, [dontAskAgain, updateSettings]) + // Never closes the dialog: only the user's own ways out do, and the store once nothing is left. const resume = useCallback( async (sessionIds: string[]): Promise => { - setBusy(true) - try { - void persistPreference() - await continueNativeChatRestartOffer(sessionIds) - } finally { - setBusy(false) - // Stays open when a chat did not carry on: its row now says what to do about it. - if (getNativeChatRestartOffer().failed.length === 0) { - consumeNativeChatResumeOnRestartDialogRequest() - } - } + void persistPreference() + await continueNativeChatRestartOffer(sessionIds) }, [persistPreference] ) @@ -173,7 +182,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { { - if (!next && !busy) { + if (!next) { snooze() } }} @@ -217,7 +226,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { candidates={rows} listedAt={listedAt} busy={busy} - selected={selected} + selected={ticked} onToggle={toggleSelected} failureFor={(sessionId) => failureBySession.get(sessionId)} onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} @@ -258,7 +267,11 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { variant="default" size="sm" disabled={busy || chosen.length === 0} - onClick={() => void resume(chosen)} + onClick={() => { + // Resume hands the run to the status bar. + consumeNativeChatResumeOnRestartDialogRequest() + void resume(chosen) + }} > {busy ? translate('auto.components.NativeChatResumeOnRestartModal.resuming', 'Resuming…') diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 6d143777e35..fa16bc7d247 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -69,7 +69,11 @@ vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ })) vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ - default: () =>
+ default: ({ onEditingChange }: { onEditingChange?: (editing: boolean) => void }) => ( +
+ +
+ ) })) vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ @@ -373,6 +377,19 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(advancedButton?.className).toContain('focus-visible:ring-inset') }) + it('shows only preset actions while editing and prevents hiding the draft', () => { + current = renderCard({ + advancedOpen: true, + sparseControlsEnabled: true, + canUseSparseCheckout: true + }) + const buttons = [...current.container.querySelectorAll('button')] + const button = (label: string) => buttons.find((node) => node.textContent?.includes(label)) + act(() => button('Begin preset')?.click()) + expect(button('Advanced')?.disabled).toBe(true) + expect(current.container.textContent).not.toContain('Create workspace') + }) + it('removes collapsed Advanced controls from the Tab order', () => { current = renderCard({ advancedOpen: false, branchesEnabled: true }) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index b406fb0aeb8..5dac7dc8246 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -95,6 +95,7 @@ export default function NewWorkspaceComposerCard( const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() const projectDescriptionId = React.useId() + const [sparseEditing, setSparseEditing] = React.useState(false) const [addRemoteHostMode, setAddRemoteHostMode] = React.useState(null) const [setLocationOption, setSetLocationOption] = React.useState( null @@ -287,6 +288,7 @@ export default function NewWorkspaceComposerCard(
-
- -
+ {!sparseEditing ? ( +
+ +
+ ) : null} {setLocationDialogMounted ? ( diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 37d20b831e8..5d38cb0739c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -73,6 +73,7 @@ function ComposerModalBody({ modalData: ComposerModalData onClose: () => void }): React.JSX.Element { + const dialogRef = useRef(null) const submitCancelledRef = useRef(false) const handleDismiss = useCallback(() => { submitCancelledRef.current = true @@ -83,7 +84,13 @@ function ComposerModalBody({ return ( !open && handleDismiss()}> { + if (dialogRef.current?.querySelector('[data-sparse-preset-editor]')) { + event.preventDefault() + } + }} onOpenAutoFocus={(event) => { // Why: Radix's FocusScope fires this once the dialog has mounted. // preventDefault stops it from focusing whatever first-tabbable it @@ -261,7 +268,7 @@ function QuickTabBody({ if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current)) { return } - if (createDisabled) { + if (createDisabled || composerRef.current?.hasAttribute('data-sparse-preset-editing')) { return } event.preventDefault() diff --git a/src/renderer/src/components/StateIndicatorTooltip.test.tsx b/src/renderer/src/components/StateIndicatorTooltip.test.tsx index 70bb8272425..819588f8969 100644 --- a/src/renderer/src/components/StateIndicatorTooltip.test.tsx +++ b/src/renderer/src/components/StateIndicatorTooltip.test.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import { STATE_INDICATOR_TOOLTIP_DELAY_MS, StateIndicatorTooltip } from './StateIndicatorTooltip' +import { StateIndicatorTooltip } from './StateIndicatorTooltip' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ delayDuration, children }: { delayDuration: number; children: ReactNode }) => ( @@ -23,7 +23,6 @@ describe('StateIndicatorTooltip', () => { ) - expect(STATE_INDICATOR_TOOLTIP_DELAY_MS).toBe(200) expect(markup).toContain('data-delay-duration="200"') expect(markup).toContain('data-tooltip-content=""') expect(markup).toContain('data-side="top"') diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index aa752c4b50f..1f7b2ebb391 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -134,7 +134,6 @@ export function AgentSessionContinuationDialog({ prompt, worktreeId: request.worktreeId, groupId: request.groupId, - workspacePath: request.workspacePath, initialCwd: request.initialCwd, launchSource: request.launchSource }) diff --git a/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx b/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx index ca8a4a916c0..4199d2a7a77 100644 --- a/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx +++ b/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx @@ -39,7 +39,6 @@ vi.mock('./ArtifactListRow', async (importOriginal) => { const { ArtifactCollection } = await import('./ArtifactCollection') const { TooltipProvider } = await import('@/components/ui/tooltip') const { ARTIFACTS_TABLE_ROW_HEIGHT_PX } = await import('./artifacts-table-layout') -const { LIST_TABLE_ROW_DIVIDER_CLASS } = await import('@/lib/list-table-layout') const VIEWPORT_HEIGHT_PX = 600 /** @@ -698,12 +697,6 @@ describe('artifacts list windowing — above the threshold', () => { ) }) - // Why exact: an added token is as damaging as a removed one — `mb-px` alone pushes the real row - // to 54px and invalidates the estimate above, and an opacity token restyles every hairline. - it('pins the row divider to the one bottom hairline', () => { - expect(LIST_TABLE_ROW_DIVIDER_CLASS).toBe('border-b border-border/50') - }) - it('mounts only a bounded window for a 500-artifact list', () => { renderCollection({ items: artifacts(500) }) diff --git a/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx index 69de2374d27..2772a6ae24d 100644 --- a/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx +++ b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx @@ -5,7 +5,7 @@ import { installMonacoEditorFindShortcut } from '@/components/editor/editor-shor import { syncContentOnMount, syncContentUpdate } from '@/components/editor/monaco-content-sync' import { isMonacoFindWidgetOpen } from '@/components/editor/monaco-find-widget' import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' -import { resolveDocumentTheme } from '@/lib/document-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import '@/lib/monaco-setup' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -74,7 +74,7 @@ export function AutomationEditorPromptEditor({ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) const fontFamily = resolveEditorFontFamily(settings) - const isDark = resolveDocumentTheme(settings?.theme ?? 'system') + const isDark = useDocumentDarkTheme() const options = useMemo( () => buildAutomationPromptEditorOptions({ diff --git a/src/renderer/src/components/automations/AutomationTimeField.test.tsx b/src/renderer/src/components/automations/AutomationTimeField.test.tsx index d085d41385d..a4d6c06d48c 100644 --- a/src/renderer/src/components/automations/AutomationTimeField.test.tsx +++ b/src/renderer/src/components/automations/AutomationTimeField.test.tsx @@ -7,9 +7,7 @@ import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' import { AutomationTimeField, - formatAutomationTimeFromClockParts, formatAutomationTimeInput, - getAutomationClockParts, parseAutomationTime, resolveDigitCommit, resolveDigitStep @@ -28,21 +26,6 @@ describe('AutomationTimeField helpers', () => { expect(formatAutomationTimeInput(0, 0)).toBe('00:00') }) - it('converts between 12h clock parts and 24h storage', () => { - expect(getAutomationClockParts('00:05')).toEqual({ hour12: 12, minute: 5, period: 'AM' }) - expect(getAutomationClockParts('13:30')).toEqual({ hour12: 1, minute: 30, period: 'PM' }) - expect(getAutomationClockParts('12:00')).toEqual({ hour12: 12, minute: 0, period: 'PM' }) - expect(formatAutomationTimeFromClockParts({ hour12: 12, minute: 5, period: 'AM' })).toBe( - '00:05' - ) - expect(formatAutomationTimeFromClockParts({ hour12: 1, minute: 30, period: 'PM' })).toBe( - '13:30' - ) - expect(formatAutomationTimeFromClockParts({ hour12: 12, minute: 0, period: 'PM' })).toBe( - '12:00' - ) - }) - it('resolves digit commits with empty → min and range clamp', () => { expect(resolveDigitCommit('', 9, 1, 12)).toBe(1) expect(resolveDigitCommit('', 15, 0, 59)).toBe(0) diff --git a/src/renderer/src/components/automations/AutomationTimeField.tsx b/src/renderer/src/components/automations/AutomationTimeField.tsx index 9eb842c170a..92a3639cff2 100644 --- a/src/renderer/src/components/automations/AutomationTimeField.tsx +++ b/src/renderer/src/components/automations/AutomationTimeField.tsx @@ -24,7 +24,7 @@ export function formatAutomationTimeInput(hour: number, minute: number): string return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` } -export function getAutomationClockParts(time: string): AutomationClockParts { +function getAutomationClockParts(time: string): AutomationClockParts { const { hour, minute } = parseAutomationTime(time) return { hour12: hour % 12 === 0 ? 12 : hour % 12, @@ -33,7 +33,7 @@ export function getAutomationClockParts(time: string): AutomationClockParts { } } -export function formatAutomationTimeFromClockParts(parts: AutomationClockParts): string { +function formatAutomationTimeFromClockParts(parts: AutomationClockParts): string { const hour24 = parts.period === 'AM' ? parts.hour12 === 12 diff --git a/src/renderer/src/components/automations/automation-host-picker-groups.test.ts b/src/renderer/src/components/automations/automation-host-picker-groups.test.ts index bf4632c607f..80f735d9d6f 100644 --- a/src/renderer/src/components/automations/automation-host-picker-groups.test.ts +++ b/src/renderer/src/components/automations/automation-host-picker-groups.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it } from 'vitest' import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' -import { - ALL_HOSTS_OPTION_VALUE, - automationHostFilterForEntry, - automationHostSearchText, - groupAutomationHostEntriesByAuthority -} from './automation-host-picker-groups' +import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups' function desktopSsh(targetId: string, label: string): AutomationHostCatalogEntry { return { @@ -76,24 +71,4 @@ describe('automation host picker groups', () => { expect(groups).toHaveLength(2) expect(groups.map((group) => group.entries.length)).toEqual([1, 1]) }) - - it('builds a stable filter that carries no incarnation', () => { - expect(automationHostFilterForEntry(desktopSsh('t1', 'web-01'))).toEqual({ - kind: 'host', - host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } } - }) - }) - - it('uses an All hosts sentinel that cannot collide with a host stable key', () => { - const keys = [desktopSsh('t1', 'web-01'), runtimeSelf('env-a', 'Alpha box')].map( - (entry) => entry.stableKey - ) - expect(keys).not.toContain(ALL_HOSTS_OPTION_VALUE) - expect(keys.every((key) => key.startsWith('host:'))).toBe(true) - }) - - it('indexes search text over both the host and its authority', () => { - expect(automationHostSearchText(runtimeSelf('env-a', 'Alpha Box'))).toBe('alpha box alpha box') - expect(automationHostSearchText(desktopSsh('t1', 'WEB-01'))).toBe('this computer web-01') - }) }) diff --git a/src/renderer/src/components/automations/automation-host-picker-groups.ts b/src/renderer/src/components/automations/automation-host-picker-groups.ts index 55784c0b6a2..789654b1b21 100644 --- a/src/renderer/src/components/automations/automation-host-picker-groups.ts +++ b/src/renderer/src/components/automations/automation-host-picker-groups.ts @@ -1,11 +1,7 @@ -import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' -/** Sentinel for the All hosts option; stable keys are `host:`-namespaced, so it cannot collide. */ -export const ALL_HOSTS_OPTION_VALUE = '__all_automation_hosts__' - export type AutomationHostPickerGroup = { authorityKey: string authorityLabel: string @@ -35,17 +31,3 @@ export function groupAutomationHostEntriesByAuthority( } return groups } - -export function automationHostFilterForEntry( - entry: AutomationHostCatalogEntry -): AutomationHostFilter { - return { kind: 'host', host: entry.stableRef } -} - -/** - * Text a searchable picker matches against. Built once per entry by the caller, - * never inside a comparator. - */ -export function automationHostSearchText(entry: AutomationHostCatalogEntry): string { - return `${entry.authorityLabel} ${entry.label}`.toLocaleLowerCase() -} diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts index 5e9e880726f..1e1b88cfcab 100644 --- a/src/renderer/src/components/automations/automation-list-search.test.ts +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -8,15 +8,11 @@ import { AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, - automationListSearchFieldsMatch, automationListSearchIndexMatches, buildAutomationListSearchFingerprint, buildAutomationListSearchIndex, buildAutomationProjectSearchText, clampAutomationListSearchQueryInput, - filterByActiveAutomationListSearchQuery, - filterByAutomationListSearch, - filterByAutomationListSearchIndex, getActiveAutomationListSearchQuery, isAutomationListSearchQueryTooLarge, normalizeAutomationListSearchField, @@ -38,19 +34,6 @@ describe('automation-list-search', () => { expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true) expect(getActiveAutomationListSearchQuery(oversized)).toBeNull() expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' }) - expect( - automationListSearchFieldsMatch( - { name: 'Auto PR', project: 'orca', prompt: 'nudge' }, - oversized - ) - ).toBe(false) - - const items = [ - { id: '1', name: 'Auto PR', project: 'orca', prompt: 'nudge' }, - { id: '2', name: 'Nightly', project: 'mobile', prompt: 'ship' } - ] - // Why: oversized paste must leave the list unfiltered, not blank it. - expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items) }) it('rejects queries over the byte limit but under the code-unit limit', () => { @@ -64,8 +47,6 @@ describe('automation-list-search', () => { it('treats whitespace-only queries as inactive (no search work)', () => { expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull() expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' }) - const items = [{ name: 'A', project: 'p1', prompt: 'one' }] - expect(filterByAutomationListSearch(items, ' ', (item) => item)).toBe(items) }) it('clamps stored query input so multi-MB pastes are discarded', () => { @@ -123,26 +104,6 @@ describe('automation-list-search', () => { expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true) }) - it('caps the searchable prompt at the first 2,048 characters', () => { - // Design doc: the bound is a hard requirement, not a tuning knob. - expect(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS).toBe(2048) - }) - - it('matches workspace, agent, and host alongside name, project, and prompt', () => { - const fields = { - name: 'Auto PR assignment', - project: 'orca / main', - workspace: 'feature/login-retry', - agent: 'Claude Code', - host: 'build-box', - prompt: 'Assign reviewers for open PRs' - } - for (const query of ['assignment', 'ORCA', 'login-retry', 'claude', 'build-box', 'reviewers']) { - expect(automationListSearchFieldsMatch(fields, query)).toBe(true) - } - expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) - }) - it('bounds every indexed field, so no axis grows with its source', () => { const index = buildAutomationListSearchIndex({ name: 'n'.repeat(10_000), @@ -192,53 +153,6 @@ describe('automation-list-search', () => { ) }) - it('matches name, project, or prompt', () => { - const fields = { - name: 'Auto PR assignment', - project: 'orca / main', - prompt: 'Assign reviewers for open PRs' - } - expect(automationListSearchFieldsMatch(fields, 'assignment')).toBe(true) - expect(automationListSearchFieldsMatch(fields, 'ORCA')).toBe(true) - expect(automationListSearchFieldsMatch(fields, 'reviewers')).toBe(true) - expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) - }) - - it('filters by active query without re-resolving bounds', () => { - const items = [ - { id: '1', name: 'Auto Issue assignment', project: 'orca', prompt: 'triage issues' }, - { id: '2', name: 'Nightly deploy', project: 'mobile', prompt: 'ship apk' }, - { id: '3', name: 'PR nudge', project: 'orca', prompt: 'remind reviewers' } - ] - const indexes = items.map((item) => - buildAutomationListSearchIndex({ - name: item.name, - project: item.project, - prompt: item.prompt - }) - ) - expect( - filterByActiveAutomationListSearchQuery(items, indexes, 'apk').map((item) => item.id) - ).toEqual(['2']) - expect( - filterByAutomationListSearchIndex(items, indexes, 'orca').map((item) => item.id) - ).toEqual(['1', '3']) - expect(filterByAutomationListSearchIndex(items, indexes, ' ')).toBe(items) - expect( - filterByAutomationListSearchIndex( - items, - indexes, - 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) - ) - ).toBe(items) - // Why: a desynchronized index must leave the list unfiltered, not blank it. - expect( - filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map( - (item) => item.id - ) - ).toEqual(['1', '2', '3']) - }) - it('builds a stable fingerprint from search sources only', () => { const sources = [ { name: 'A', project: 'p1', prompt: 'one' }, diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts index 68df699c0c0..944fab58786 100644 --- a/src/renderer/src/components/automations/automation-list-search.ts +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -1,8 +1,3 @@ -import { - getActiveAutomationListSearchQuery, - resolveAutomationListSearchQuery -} from './automation-list-search-query' - export { AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, clampAutomationListSearchQueryInput, @@ -111,81 +106,6 @@ export function automationListSearchIndexMatches( return false } -export function automationListSearchFieldsMatch( - fields: AutomationListSearchFields, - rawQuery: string -): boolean { - const resolved = resolveAutomationListSearchQuery(rawQuery) - if (resolved.status === 'too_large') { - return false - } - if (resolved.status === 'inactive') { - return true - } - return automationListSearchIndexMatches(buildAutomationListSearchIndex(fields), resolved.query) -} - -/** - * Filters with an already-resolved active query. Callers must pass null/skip - * when search is inactive or too large so this never runs "for free". - */ -export function filterByActiveAutomationListSearchQuery( - items: readonly T[], - indexes: readonly AutomationListSearchIndex[], - activeQuery: string -): T[] { - if (indexes.length !== items.length) { - return [...items] - } - const matches: T[] = [] - for (let i = 0; i < items.length; i += 1) { - const item = items[i] - const index = indexes[i] - if (item !== undefined && index && automationListSearchIndexMatches(index, activeQuery)) { - matches.push(item) - } - } - return matches -} - -/** - * Filters items by a prebuilt index. Empty and oversized queries leave the - * original array reference untouched so the list stays unfiltered and search - * work is skipped entirely. - */ -export function filterByAutomationListSearchIndex( - items: readonly T[], - indexes: readonly AutomationListSearchIndex[], - rawQuery: string -): readonly T[] { - const activeQuery = getActiveAutomationListSearchQuery(rawQuery) - if (activeQuery === null) { - return items - } - return filterByActiveAutomationListSearchQuery(items, indexes, activeQuery) -} - -/** Builds indexes then filters. Prefer prebuilt indexes when filtering often. */ -export function filterByAutomationListSearch( - items: readonly T[], - rawQuery: string, - getFields: (item: T) => AutomationListSearchFields -): readonly T[] { - const activeQuery = getActiveAutomationListSearchQuery(rawQuery) - if (activeQuery === null) { - return items - } - const matches: T[] = [] - for (const item of items) { - if ( - automationListSearchIndexMatches(buildAutomationListSearchIndex(getFields(item)), activeQuery) - ) { - matches.push(item) - } - } - return matches -} - /** * Content fingerprint for search-relevant fields only. Used so list refresh * ticks that replace arrays with equivalent search content do not rebuild diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index e07399319fb..43319886180 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -288,7 +288,7 @@ describe('owner-fenced mutations', () => { }) it('fences a desktop mutation over the local runtime target with the same precondition', async () => { - const { deleteAutomationForOwner, updateAutomationForOwner } = await client() + const { updateAutomationForOwner } = await client() callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) await updateAutomationForOwner({ authority: DESKTOP, selector: { kind: 'self' } }, 'a1', { enabled: true @@ -300,7 +300,6 @@ describe('owner-fenced mutations', () => { expect.anything() ) expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() - expect(typeof deleteAutomationForOwner).toBe('function') }) }) diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index ed8cc04c31f..f7bba3bfe9a 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -83,7 +83,7 @@ describe('cookie-import Google disclosure footer', () => { ) diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx index 679cc555e4a..7f93d392f30 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx @@ -113,13 +113,6 @@ function emitProgress(overrides: Partial = {}): vo } describe('ClientHostedBrowserPagePane download notices', () => { - it('subscribes to the download lifecycle for the page it renders', () => { - renderPane() - - expect(requested.listenerCount()).toBe(1) - expect(finished.listenerCount()).toBe(1) - }) - it('names the remote workspace destination when the download lands there', () => { renderPane() diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx index 80762dcb87f..79ecdda5799 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx @@ -87,18 +87,6 @@ describe('ClientHostedBrowserPagePane popup notices', () => { ) }) - it('collapses a retrying site onto one notice per origin', () => { - renderPane() - - emitPopup() - emitPopup() - emitPopup() - - expect(toastMocks.message).toHaveBeenCalledTimes(3) - const ids = toastMocks.message.mock.calls.map((call) => (call[1] as { id: string }).id) - expect(new Set(ids).size).toBe(1) - }) - it('silences in-Orca opens but reports external opens', () => { renderPane() diff --git a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx index 4eb4174211b..24524a10dc7 100644 --- a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx +++ b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx @@ -1,6 +1,4 @@ import React from 'react' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' @@ -92,19 +90,4 @@ describe('BrowserAnnotationSendMenuContent', () => { onPromptDelivered }) }) - - it('is wired into both browser annotation send surfaces', () => { - const bannerSource = readFileSync( - fileURLToPath(new URL('../assemble-chrome/browser-page-chrome-banners.tsx', import.meta.url)), - 'utf8' - ) - const traySource = readFileSync( - fileURLToPath(new URL('./browser-page-annotation-tray.tsx', import.meta.url)), - 'utf8' - ) - const sendSurfaces = `${bannerSource}\n${traySource}` - - expect(sendSurfaces.match(/ { - it('pins context-menu opens to the runtime that owns the pane', () => { - const source = readFileSync( - fileURLToPath(new URL('../stream-remote/remote-browser-page-pane.tsx', import.meta.url)), - 'utf8' - ) - const paneStart = source.indexOf('function RemoteBrowserPagePane') - const actionStart = source.indexOf('void openWorkspaceBrowserTab({', paneStart) - const actionEnd = source.indexOf('}).catch((error) => {', actionStart) - const openRequest = source.slice(actionStart, actionEnd) - - expect(openRequest).toContain('workspaceId: worktreeId') - expect(openRequest).toContain('expectedRuntimeEnvironmentId: runtimeEnvironmentId') - expect(openRequest).toContain("placementPreference: 'server'") - // A background link follows its source wrapper and never selects the workspace again. - expect(openRequest).toContain( - '...(sourceUnifiedTab ? { afterTabId: sourceUnifiedTab.id } : {})' - ) - expect(openRequest).toContain('selectWorktree: false') - }) -}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts deleted file mode 100644 index 9e42075283c..00000000000 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -describe('BrowserPagePane render IPC boundary', () => { - it('derives toolbar URLs without querying the webview', () => { - const source = readFileSync( - fileURLToPath(new URL('./browser-page-pane.tsx', import.meta.url)), - 'utf8' - ) - const start = source.indexOf('const isBlankTab =', source.indexOf('function BrowserPagePane')) - const end = source.indexOf('useEffect(() => {', start) - const renderUrlDerivation = source.slice(start, end) - - expect(renderUrlDerivation).toContain('getLiveBrowserUrl(browserTab.id)') - expect(renderUrlDerivation).not.toContain('webviewRef') - expect(renderUrlDerivation).not.toContain('.getURL(') - }) -}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx index 9d536c1ba6f..7bb75c50c1e 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx @@ -201,51 +201,41 @@ describe('deferred browser lifecycle through the overlay and SSH gate', () => { } ) - it.each(['url', 'document'])( - 'retains %s content across page and tab switches within a visible worktree', - (kind) => { - if (kind === 'document') { - mocks.state!.browserPagesByWorkspace.a[0].docLocation = { - kind: 'workspace-doc', - worktreeId: 'wt-1', - filePath: '/workspace/report.html' - } - } - const view = render(surface()) - const page = view.container.querySelector('[data-page-id="a-1"]')! - page.value = 'unsaved state' - page.scrollTop = 80 - expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) - selectPage('a-2') - redraw(view) - expect(page.isConnected).toBe(true) - expect(page.dataset.active).toBe('false') - selectTab('b') - redraw(view) - expect(page.isConnected).toBe(true) - selectPage('a-1') - selectTab('a') - redraw(view) - expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page) - expect(page.value).toBe('unsaved state') - expect(page.scrollTop).toBe(80) - expect(page.dataset.active).toBe('true') - expect(view.container.querySelector('[data-page-id="b-2"]')).toBeNull() + it('retains page content across page and tab switches within a visible worktree', () => { + const view = render(surface()) + const page = view.container.querySelector('[data-page-id="a-1"]')! + page.value = 'unsaved state' + page.scrollTop = 80 + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + selectPage('a-2') + redraw(view) + expect(page.isConnected).toBe(true) + expect(page.dataset.active).toBe('false') + selectTab('b') + redraw(view) + expect(page.isConnected).toBe(true) + selectPage('a-1') + selectTab('a') + redraw(view) + expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page) + expect(page.value).toBe('unsaved state') + expect(page.scrollTop).toBe(80) + expect(page.dataset.active).toBe('true') + expect(view.container.querySelector('[data-page-id="b-2"]')).toBeNull() - redraw(view, false) - expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0) - redraw(view) - const restored = view.container.querySelector('[data-page-id="a-1"]')! - expect(restored).not.toBe(page) - expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) + redraw(view, false) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0) + redraw(view) + const restored = view.container.querySelector('[data-page-id="a-1"]')! + expect(restored).not.toBe(page) + expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1) - mocks.state!.browserPagesByWorkspace.a = mocks.state!.browserPagesByWorkspace.a.slice(1) - mocks.state!.browserTabsByWorktree['wt-1'] = [...mocks.state!.browserTabsByWorktree['wt-1']] - redraw(view) - expect(page.isConnected).toBe(false) - expect(restored.isConnected).toBe(false) - } - ) + mocks.state!.browserPagesByWorkspace.a = mocks.state!.browserPagesByWorkspace.a.slice(1) + mocks.state!.browserTabsByWorktree['wt-1'] = [...mocks.state!.browserTabsByWorktree['wt-1']] + redraw(view) + expect(page.isConnected).toBe(false) + expect(restored.isConnected).toBe(false) + }) it('keeps a prepared SSH gate and its opened pages alive when switching tabs', async () => { mocks.executionHostId = 'ssh:target-a' diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts index d2302d8151d..e3345d57558 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts @@ -40,16 +40,6 @@ describe('formatBrowserImportSummary', () => { ).toBe('Detected: Google Chrome, Safari, Microsoft Edge, Arc, +2 more.') }) - it('lists supported import sources before detection runs', () => { - expect( - formatBrowserImportSummary({ - detectedBrowsers: [], - detectedBrowsersLoaded: false, - supportedImportLabels: SUPPORTED_LABELS - }) - ).toBe('Import from: Google Chrome, Microsoft Edge, Arc, Brave, +4 more.') - }) - it('falls back to supported import sources when detection finds nothing', () => { expect( formatBrowserImportSummary({ diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx index 3c329728846..9c4c171d5a6 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx @@ -168,27 +168,6 @@ describe('browser workspace pane retention props', () => { expect(renderedIds()).toEqual(['page-0']) }) - it.each(['automation', 'mobile', 'viewer'])('loads an inactive page for %s only', (consumer) => { - const token = consumer === 'automation' ? acquireBrowserAutomationVisibility('page-b') : null - if (consumer === 'mobile') { - hydrateBrowserDrivers([ - { browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } } - ]) - } - if (consumer === 'viewer') { - hydrateBrowserRemoteViewerPages(['page-b']) - } - try { - const view = render() - expect(view.container.querySelector('[data-browser-page-id="page-a"]')).toBeNull() - expect(view.container.querySelector('[data-browser-page-id="page-b"]')).not.toBeNull() - } finally { - if (token) { - releaseBrowserAutomationVisibility(token) - } - } - }) - it('threads all three retention terms to the page that owns them', () => { renderWorkspacePane() expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts deleted file mode 100644 index 647b6696907..00000000000 --- a/src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, expect, it } from 'vitest' - -/** - * Tests for the browser context menu positioning pipeline. - * - * Pipeline: - * 1. Main: captures screen.getCursorScreenPoint() at context-menu time - * 2. Main: sends screenX/screenY + guest params.x/y to renderer - * 3. Renderer: converts (screenX - window.screenX, screenY - window.screenY) / zoomFactor - * 4. Renderer: createPortal(div, document.body), position: fixed at those coords - * 5. Self-correction: useLayoutEffect measures getBoundingClientRect(), - * adjusts left/top if CSS containing blocks shifted the element - * - * Invariant: the context menu's top-left corner is at the cursor. - */ - -function computeViewportCoords( - screenX: number, - screenY: number, - windowScreenX: number, - windowScreenY: number, - zoomFactor: number -): { x: number; y: number } { - return { - x: Math.round((screenX - windowScreenX) / zoomFactor), - y: Math.round((screenY - windowScreenY) / zoomFactor) - } -} - -function computeCorrection( - targetX: number, - targetY: number, - measuredLeft: number, - measuredTop: number -): { left: number; top: number; corrected: boolean } { - const dx = targetX - measuredLeft - const dy = targetY - measuredTop - if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { - return { left: targetX + dx, top: targetY + dy, corrected: true } - } - return { left: targetX, top: targetY, corrected: false } -} - -describe('screen-to-viewport coordinate conversion', () => { - it('converts screen coords to viewport at zoom 1.0', () => { - const result = computeViewportCoords(650, 400, 100, 50, 1) - expect(result.x).toBe(550) - expect(result.y).toBe(350) - }) - - it('converts screen coords to viewport at zoom 1.2', () => { - // Screen cursor at (700, 500), window at (100, 50), zoom 1.2 - // Viewport = (600, 450) / 1.2 = (500, 375) - const result = computeViewportCoords(700, 500, 100, 50, 1.2) - expect(result.x).toBe(500) - expect(result.y).toBe(375) - }) - - it('converts screen coords to viewport at zoom 0.8 (zoomed out)', () => { - // (600 - 100) / 0.8 = 625 - const result = computeViewportCoords(600, 450, 100, 50, 0.8) - expect(result.x).toBe(625) - expect(result.y).toBe(500) - }) - - it('handles cursor at window origin', () => { - const result = computeViewportCoords(100, 50, 100, 50, 1) - expect(result.x).toBe(0) - expect(result.y).toBe(0) - }) - - it('handles cursor at window origin with zoom', () => { - const result = computeViewportCoords(100, 50, 100, 50, 1.5) - expect(result.x).toBe(0) - expect(result.y).toBe(0) - }) -}) - -describe('self-correction math', () => { - it('no correction when position matches target', () => { - const result = computeCorrection(300, 200, 300, 200) - expect(result.corrected).toBe(false) - expect(result.left).toBe(300) - expect(result.top).toBe(200) - }) - - it('no correction for sub-pixel differences (within 1px)', () => { - const result = computeCorrection(300, 200, 300.5, 200.8) - expect(result.corrected).toBe(false) - }) - - it('corrects positive offset (ancestor pushed element right/down)', () => { - const result = computeCorrection(300, 200, 350, 230) - expect(result.left).toBe(250) - expect(result.top).toBe(170) - expect(result.corrected).toBe(true) - }) - - it('corrects negative offset (ancestor pulled element left/up)', () => { - const result = computeCorrection(300, 200, 250, 170) - expect(result.left).toBe(350) - expect(result.top).toBe(230) - expect(result.corrected).toBe(true) - }) - - it('corrected position reconstructs to target after same offset', () => { - const target = { x: 400, y: 350 } - const offset = { x: 50, y: 30 } - const measured = { left: target.x + offset.x, top: target.y + offset.y } - const result = computeCorrection(target.x, target.y, measured.left, measured.top) - expect(result.left + offset.x).toBe(target.x) - expect(result.top + offset.y).toBe(target.y) - }) -}) - -function computeEdgeFlip( - cursorX: number, - cursorY: number, - menuWidth: number, - menuHeight: number, - viewportWidth: number, - viewportHeight: number -): { x: number; y: number } { - let x = cursorX - let y = cursorY - if (cursorX + menuWidth > viewportWidth) { - x = cursorX - menuWidth - } - if (cursorY + menuHeight > viewportHeight) { - y = cursorY - menuHeight - } - x = Math.max(0, x) - y = Math.max(0, y) - return { x, y } -} - -describe('viewport edge flipping', () => { - const menuW = 208 - const menuH = 300 - const vw = 1440 - const vh = 900 - - it('no flip when menu fits entirely', () => { - const result = computeEdgeFlip(400, 300, menuW, menuH, vw, vh) - expect(result.x).toBe(400) - expect(result.y).toBe(300) - }) - - it('flips left when menu overflows right edge', () => { - const result = computeEdgeFlip(1400, 300, menuW, menuH, vw, vh) - expect(result.x).toBe(1400 - menuW) - expect(result.y).toBe(300) - }) - - it('flips up when menu overflows bottom edge', () => { - const result = computeEdgeFlip(400, 750, menuW, menuH, vw, vh) - expect(result.x).toBe(400) - expect(result.y).toBe(750 - menuH) - }) - - it('flips both when menu overflows bottom-right corner', () => { - const result = computeEdgeFlip(1400, 750, menuW, menuH, vw, vh) - expect(result.x).toBe(1400 - menuW) - expect(result.y).toBe(750 - menuH) - }) - - it('clamps to zero if flip would go negative', () => { - const result = computeEdgeFlip(100, 50, menuW, menuH, vw, vh) - // 100 + 208 = 308 < 1440, no flip needed horizontally - expect(result.x).toBe(100) - // 50 + 300 = 350 < 900, no flip needed vertically - expect(result.y).toBe(50) - - // Small viewport forces flip, but cursor near origin clamps to 0 - const tight = computeEdgeFlip(50, 40, menuW, menuH, 200, 200) - // 50 - 208 = -158 → clamped to 0 - expect(tight.x).toBe(0) - // 40 - 300 = -260 → clamped to 0 - expect(tight.y).toBe(0) - }) - - it('exactly fits without flipping (edge-touching)', () => { - // Menu right edge exactly at viewport: 1232 + 208 = 1440 - const result = computeEdgeFlip(1232, 600, menuW, menuH, vw, vh) - expect(result.x).toBe(1232) - expect(result.y).toBe(600) - }) - - it('flips when 1px past viewport', () => { - // 1233 + 208 = 1441 > 1440 - const result = computeEdgeFlip(1233, 600, menuW, menuH, vw, vh) - expect(result.x).toBe(1233 - menuW) - }) -}) - -describe('full pipeline', () => { - it('screen coords at zoom 1.0 → correct viewport position', () => { - const screenCursor = { x: 650, y: 400 } - const windowPos = { x: 100, y: 50 } - - const menuPos = computeViewportCoords( - screenCursor.x, - screenCursor.y, - windowPos.x, - windowPos.y, - 1 - ) - - expect(menuPos.x).toBe(550) - expect(menuPos.y).toBe(350) - }) - - it('screen coords at zoom 1.44 → correctly scaled viewport position', () => { - // zoom level 2 → factor 1.2^2 = 1.44 - const screenCursor = { x: 388, y: 194 } - const windowPos = { x: 100, y: 50 } - - const menuPos = computeViewportCoords( - screenCursor.x, - screenCursor.y, - windowPos.x, - windowPos.y, - 1.44 - ) - - expect(menuPos.x).toBe(200) - expect(menuPos.y).toBe(100) - }) - - it('pipeline + self-correction compensates any CSS offset', () => { - const screenCursor = { x: 650, y: 400 } - const windowPos = { x: 100, y: 50 } - - const menuPos = computeViewportCoords( - screenCursor.x, - screenCursor.y, - windowPos.x, - windowPos.y, - 1 - ) - const cssOffset = { x: 60, y: 40 } - const measured = { left: menuPos.x + cssOffset.x, top: menuPos.y + cssOffset.y } - - const corrected = computeCorrection(menuPos.x, menuPos.y, measured.left, measured.top) - expect(corrected.left + cssOffset.x).toBe(menuPos.x) - expect(corrected.top + cssOffset.y).toBe(menuPos.y) - }) - - it('works without correction when no CSS offset exists', () => { - const screenCursor = { x: 650, y: 400 } - const windowPos = { x: 100, y: 50 } - - const menuPos = computeViewportCoords( - screenCursor.x, - screenCursor.y, - windowPos.x, - windowPos.y, - 1 - ) - const corrected = computeCorrection(menuPos.x, menuPos.y, menuPos.x, menuPos.y) - - expect(corrected.corrected).toBe(false) - expect(corrected.left).toBe(menuPos.x) - expect(corrected.top).toBe(menuPos.y) - }) -}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx index 28843482186..69ca863f9f2 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx @@ -173,14 +173,6 @@ describe('useBrowserPageChromeFocus', () => { expect(useAppStore.getState().pendingAddressBarFocusByPageId[PAGE_ID]).toBe(true) }) - it('claims a durable palette request aimed at the address bar', () => { - act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'address-bar' })) - - renderChrome() - - expectAddressBarFocusedAndSelected() - }) - it('releases the address-bar latch once a palette grab has run its frames', () => { renderChrome() @@ -422,65 +414,4 @@ describe('useBrowserPageChromeFocus', () => { expect(document.activeElement).toBe(addressBar('b')) }) - - it('sends the chord to the floating browser, not the focused split under it', () => { - render( - <> - -
- -
- - ) - act(() => flushFrames()) - - act(() => { - guest('floating').focus() - guest('floating').dispatchEvent( - new KeyboardEvent('keydown', { key: 'l', metaKey: true, bubbles: true, cancelable: true }) - ) - }) - - expect(document.activeElement).toBe(addressBar('floating')) - }) - - it('sends the chord to the focused split, not the floating browser over it', () => { - render( - <> -
- -
- - - ) - act(() => flushFrames()) - - act(() => { - guest('a').focus() - guest('a').dispatchEvent( - new KeyboardEvent('keydown', { key: 'l', metaKey: true, bubbles: true, cancelable: true }) - ) - }) - - expect(document.activeElement).toBe(addressBar('a')) - }) - - it('leaves the chord to the rest of the app while the pane is inactive', () => { - renderChrome({ isActive: false }) - act(() => guest().focus()) - - pressFocusAddressBarChord(true) - - expect(document.activeElement).toBe(guest()) - }) }) diff --git a/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts deleted file mode 100644 index 6937de9e6e4..00000000000 --- a/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -/** - * Page metadata has exactly one way out of this renderer: main, which owns the browser-host lease. - * - * Why a census and not a behavioural test: the failure this guards is silent. The runtime accepts - * page traffic only on the connection its lease attached on, so a publish sent as an ordinary - * runtime call is refused as a stale lease and simply never lands — the pane keeps working, the - * guest keeps navigating, and only the runtime's copy of the URL stays frozen at the create URL. - * Nothing in the rendered result changes, so only the route itself can be asserted. - */ -const ROUTE_PATH = fileURLToPath( - new URL('./browser-client-page-metadata-reporting.ts', import.meta.url) -) -const PANE_PATH = fileURLToPath(new URL('./ClientHostedBrowserPagePane.tsx', import.meta.url)) - -describe('client-hosted page metadata route', () => { - const route = readFileSync(ROUTE_PATH, 'utf8') - const pane = readFileSync(PANE_PATH, 'utf8') - - it('publishes through main and never through a plain runtime call', () => { - expect(occurrences(route, 'window.api.browser.publishClientPageMetadata')).toBe(1) - expect(occurrences(route, 'runtimeEnvironments.call')).toBe(0) - expect(occurrences(pane, 'runtimeEnvironments.call')).toBe(0) - }) - - it('reports a publish that did not land', () => { - expect(occurrences(route, 'onUnpublished:')).toBe(1) - }) - - // The pane must not build its own publisher around the route: a second construction site is a - // second chance to wire the transport wrongly, and it would not be covered by the counts above. - it('leaves the pane one way to start publishing', () => { - expect(occurrences(pane, 'createBrowserClientPageMetadataPublisher(')).toBe(0) - expect(occurrences(pane, 'startBrowserClientPageMetadataPublisher(')).toBe(1) - }) -}) - -function occurrences(source: string, needle: string): number { - return source.split(needle).length - 1 -} diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts index 9609ae643ba..71c53ee6a14 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts @@ -59,20 +59,6 @@ describe('client-hosted retained guest drag passthrough', () => { expect(rig.host().style.pointerEvents).toBe('auto') }) - it('brings a retained page created mid-drag up click-through', async () => { - const rig = createRetainedHostFixture() - const endDrag = startDrag() - await rig.mount() - - rig.attach() - - expect(rig.host().style.pointerEvents).toBe('none') - - endDrag() - - expect(rig.host().style.pointerEvents).toBe('auto') - }) - it('keeps the retained host click-through until every drag releases', async () => { const rig = createRetainedHostFixture() await rig.mount() diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts index 0c9bea078cc..25092d931a0 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts @@ -174,55 +174,6 @@ describe('browser client page retained registry', () => { expect(registry.getMemoryProfile().attachedPageCount).toBe(1) }) - it('tracks a pure pane move that fires no resize or scroll event', async () => { - const frames: FrameRequestCallback[] = [] - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frames.push(callback) - return frames.length - }) - const cancelled = vi.fn() - vi.stubGlobal('cancelAnimationFrame', cancelled) - try { - const { registry, webviews } = createRig() - const mounting = registry.mountPage(PAGE) - attach(webviews[0]!) - await mounting - const retainedHost = webviews[0]!.parentElement as HTMLDivElement - const viewport = document.createElement('div') - const rect = (left: number): DOMRect => - ({ - bottom: 260, - height: 200, - left, - right: left + 300, - top: 60, - width: 300, - x: left, - y: 60, - toJSON: () => ({}) - }) as DOMRect - const bounds = vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(rect(40)) - document.body.appendChild(viewport) - const attachment = registry.attachPage( - { browserPageId: PAGE.browserPageId, pageHostGeneration: PAGE.pageHostGeneration }, - viewport - ) - expect(retainedHost.style.cssText).toContain('left: 40px') - - // Why: dragging a tab across an even split moves the pane without resizing it. - bounds.mockReturnValue(rect(700)) - frames.splice(0).forEach((callback) => callback(0)) - expect(retainedHost.style.cssText).toContain('left: 700px') - expect(retainedHost.style.cssText).toContain('width: 300px') - - attachment.detach() - expect(cancelled).toHaveBeenCalled() - expect(retainedHost.style.cssText).toContain('left: -10000px') - } finally { - vi.unstubAllGlobals() - } - }) - it('keeps metadata revisions monotonic across visible detach and reattach', async () => { const { registry, webviews } = createRig() const mounting = registry.mountPage(PAGE) diff --git a/src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts b/src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts index 618bbf2280b..99495a87da7 100644 --- a/src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts +++ b/src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts @@ -51,17 +51,4 @@ describe('reopenBrowserPageOnServer', () => { focusOnCreate: true }) }) - - it('never closes or mutates the client-hosted page it escapes from', async () => { - mocks.createWebRuntimeSessionBrowserTab.mockClear() - - await reopenBrowserPageOnServer({ - environmentId: 'env-1', - worktreeId: 'wt-1', - lastCommittedUrl: 'https://example.com/app' - }) - - expect(mocks.createWebRuntimeSessionBrowserTab.mock.calls[0]?.[0]).not.toHaveProperty('page') - expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenCalledOnce() - }) }) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts b/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts index 231ee0bbf23..b5527404c29 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts @@ -6,7 +6,6 @@ type Listener = (event: T) => void export type PaneChannel = { subscribe: (callback: Listener) => () => void emit: (event: T) => void - listenerCount: () => number } export function paneChannel(): PaneChannel { @@ -23,8 +22,7 @@ export function paneChannel(): PaneChannel { for (const listener of listeners.slice()) { listener(event) } - }, - listenerCount: () => listeners.length + } } } diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts index f6b83348aee..ad0a6f774d1 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { - clampNumber, createBrowserAnnotationPayload, getLiveBrowserAnnotationRect } from './browser-annotation-geometry' @@ -26,12 +25,6 @@ function payload(overrides: Partial = {}): Browser } describe('browser annotation geometry', () => { - it('clamps inclusive of both ends', () => { - expect(clampNumber(5, 0, 10)).toBe(5) - expect(clampNumber(-1, 0, 10)).toBe(0) - expect(clampNumber(11, 0, 10)).toBe(10) - }) - it('uses the viewport rect for fixed targets and scroll-adjusts flowing targets', () => { const fixed = payload({ isFixed: true }) expect(getLiveBrowserAnnotationRect(fixed, { scrollX: 99, scrollY: 99, version: 2 })).toEqual( diff --git a/src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts b/src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts index b97440ced57..8381d962ef8 100644 --- a/src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts +++ b/src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts @@ -30,14 +30,6 @@ describe('browser runtime live URL cache', () => { expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/') }) - it('retains the last committed URL when a later load fails', () => { - seedLiveBrowserUrl('page-1', 'https://initial.example/') - rememberLiveBrowserUrl('page-1', 'https://committed.example/') - // A failure event records its validated URL in loadError, not the live URL cache. - - expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/') - }) - it('keeps popup page URLs independent and clears only the destroyed page', () => { seedLiveBrowserUrl('page-1', 'https://opener.example/') seedLiveBrowserUrl('popup-1', 'https://popup.example/') diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts index 3e58d4ed637..56245ab304a 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts @@ -41,10 +41,6 @@ describe('browser-focus', () => { expect(consumeBrowserFocusRequest('page-2')).toBe('address-bar') }) - it('returns null for a page id that was never queued', () => { - expect(consumeBrowserFocusRequest('nonexistent-page')).toBeNull() - }) - it('expires unconsumed requests for pages that never mount', () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts index e49302e5ab4..7b9b329ac41 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts @@ -63,13 +63,6 @@ describe('browser guest eviction veto', () => { expect(evictionRun(tabsFor(WATCHED_PAGE))).toContain(WATCHED_WORKTREE) }) - it('releases the veto when the last remote viewer leaves', () => { - hydrateBrowserRemoteViewerPages([WATCHED_PAGE]) - expect(browserTabsVetoGuestEviction(tabsFor(WATCHED_PAGE))).toBe(true) - hydrateBrowserRemoteViewerPages([]) - expect(evictionRun(tabsFor(WATCHED_PAGE))).toContain(WATCHED_WORKTREE) - }) - // Downloads are the one veto term that is not a paint term: parking a guest keeps the download // alive, but eviction unregisters it and main cancels its downloads (tab-close semantics). it('spares a page that is still writing a download', () => { diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts index 0d1364c139b..941c527b38f 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts @@ -61,7 +61,6 @@ export function useBrowserGuestPaintRetention(browserPageIds: readonly string[]) // Why one exported predicate rather than the same OR-list at each site: a hand-rolled copy stays // green when a term is added — nothing typechecks a site that never names the new signal — and the // remote-viewer term reached the panes while four copies in Terminal.tsx still had three terms. -// browser-guest-retention-site-census.test.ts holds the sites to this function. export function browserPageNeedsPaintRetention(browserPageId: string): boolean { return ( isBrowserAutomationVisible(browserPageId) || diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts deleted file mode 100644 index 5d8c6028329..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Guest retention is an OR over several independent signals, and every container from the app -// shell down to the guest has to agree on the list — Chromium stops painting inside a display:none -// subtree, so the *most* pinched ancestor wins. Adding a term (the remote-viewer signal, STA-4150) -// reached the panes while four hand-rolled copies still listed three: nothing typechecks a site -// that never names the new symbol. So the terms live behind one helper, and this census holds the -// sites to it — the classification is enforced here, not by review. - -const RENDERER_SRC = join(__dirname, '..', '..', '..') - -// The three term stores. -const RETENTION_TERM_STORES = [ - 'components/browser-pane/host-guest/browser-automation-visibility.ts', - 'lib/pane-manager/browser-mobile-driver-state.ts', - 'lib/pane-manager/browser-remote-viewer-state.ts' -] - -// ...plus the single module allowed to OR their terms together. -const RETENTION_TERM_OWNERS = [ - ...RETENTION_TERM_STORES, - 'components/browser-pane/host-guest/browser-guest-paint-retention.ts' -] - -// Every way a term store lets a caller read one term. A site that hand-rolls the OR-list from -// readers missing here would pass the census, so the last check holds this list to the exports. -const RETENTION_TERM_SYMBOLS = [ - 'isBrowserAutomationVisible', - 'useBrowserAutomationVisibilityForAny', - 'getBrowserAutomationVisiblePageIds', - 'onBrowserAutomationVisibilityChange', - 'isBrowserPageMobileDriven', - 'hasMobileDriverForAnyBrowserPage', - 'useBrowserMobileDriverForAny', - 'getBrowserMobileDrivenPageIds', - 'getDriverForBrowserPage', - 'useBrowserDriverForPage', - 'onBrowserDriverChange', - 'isBrowserPageRemotelyViewed', - 'hasRemoteViewerForAnyBrowserPage', - 'useBrowserRemoteViewerForAny', - 'getBrowserRemotelyViewedPageIds', - 'onBrowserRemoteViewerChange' -] - -const RETENTION_HELPER_SYMBOLS = [ - 'useAnyBrowserGuestNeedsPaint', - 'useBrowserGuestPaintRetention', - 'browserPageNeedsPaintRetention', - 'onBrowserGuestPaintRetentionChange', - 'browserTabsVetoGuestEviction' -] - -// Every place that decides whether a browser guest keeps painting, and the helper it must use. -const RETENTION_SITES = new Map([ - ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], - // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree - // surface hatch below cannot rescue a guest either one parked with `hidden`. - ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], - ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], - ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], - [ - 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', - ['useBrowserGuestPaintRetention'] - ], - [ - 'components/browser-pane/host-guest/browser-guest-worktree-retention.ts', - ['browserPageNeedsPaintRetention'] - ], - [ - 'components/use-terminal-browser-retention.ts', - ['browserTabsVetoGuestEviction', 'onBrowserGuestPaintRetentionChange'] - ] -]) - -// The per-page threading hooks are the carve-out: they hand one boolean per page to -// browser-page-paintability's required fields, so a missing term IS a typecheck error there. They -// still travel as a set — a caller that reads two of the three is the same omission bug. -const PER_PAGE_RETENTION_HOOKS = [ - 'useBrowserAutomationVisiblePageIds', - 'useBrowserMobileDrivenPageIds', - 'useBrowserRemotelyViewedPageIds' -] - -// One production read of a term is not a retention decision: the pane reads *who* drives the active -// page to label the overlay and lock input. Naming the exception per file rather than dropping the -// symbol keeps every other file that reads it a census failure. -const NON_RETENTION_TERM_READERS = new Map([ - [ - 'components/browser-pane/assemble-chrome/browser-workspace-pane.tsx', - ['useBrowserDriverForPage'] - ] -]) - -// Writers, hydrators and the idle sentinel: they set or seed a term rather -// than read it, so naming one is not a retention decision. -const NON_READER_TERM_EXPORTS = [ - 'acquireBrowserAutomationVisibility', - 'releaseBrowserAutomationVisibility', - 'setDriverForBrowserPage', - 'hydrateBrowserDrivers', - 'IDLE_BROWSER_DRIVER', - 'setRemoteViewersForBrowserPage', - 'hydrateBrowserRemoteViewerPages' -] - -function productionSources(): Map { - const sources = new Map() - for (const entry of readdirSync(RENDERER_SRC, { recursive: true, withFileTypes: true })) { - if (!entry.isFile() || !/\.tsx?$/.test(entry.name) || entry.name.endsWith('.d.ts')) { - continue - } - if (/\.test\.tsx?$/.test(entry.name) || /test-(harness|rig|fixtures)/.test(entry.name)) { - continue - } - const filePath = join(entry.parentPath, entry.name) - sources.set( - relative(RENDERER_SRC, filePath).split(sep).join('/'), - readFileSync(filePath, 'utf8') - ) - } - return sources -} - -function namedSymbols(source: string, symbols: readonly string[]): string[] { - return symbols.filter((symbol) => new RegExp(`\\b${symbol}\\b`).test(source)) -} - -describe('browser guest retention site census', () => { - const sources = productionSources() - - it('keeps every individual retention term behind the shared helper', () => { - const offenders = [...sources] - .filter(([file]) => !RETENTION_TERM_OWNERS.includes(file)) - .map(([file, source]) => ({ - file, - terms: namedSymbols(source, RETENTION_TERM_SYMBOLS).filter( - (term) => !(NON_RETENTION_TERM_READERS.get(file) ?? []).includes(term) - ) - })) - .filter(({ terms }) => terms.length > 0) - .map(({ file, terms }) => `${file}: ${terms.join(', ')}`) - .sort() - - expect( - offenders, - 'A retention site that names terms one by one silently keeps its old list when a new signal ' + - 'is added. Call useBrowserGuestPaintRetention / browserPageNeedsPaintRetention instead, ' + - 'and register the site in RETENTION_SITES.' - ).toEqual([]) - }) - - it('registers every consumer of the shared retention helper', () => { - const consumers = [...sources] - .filter( - ([file]) => file !== 'components/browser-pane/host-guest/browser-guest-paint-retention.ts' - ) - .filter(([, source]) => namedSymbols(source, RETENTION_HELPER_SYMBOLS).length > 0) - .map(([file]) => file) - .sort() - - expect(consumers, 'New retention sites must be listed in RETENTION_SITES.').toEqual( - [...RETENTION_SITES.keys()].sort() - ) - }) - - it('keeps each registered site consulting retention through its helper', () => { - const missing: string[] = [] - for (const [file, requiredSymbols] of RETENTION_SITES) { - const source = sources.get(file) - expect(source, `${file} is registered as a retention site but no longer exists`).toBeDefined() - for (const symbol of requiredSymbols) { - if (!new RegExp(`\\b${symbol}\\b`).test(source ?? '')) { - missing.push(`${file}: ${symbol}`) - } - } - } - expect(missing, 'A registered retention site stopped consulting retention.').toEqual([]) - }) - - it('keeps the per-page threading hooks travelling as a full set', () => { - const partial = [...sources] - .filter(([file]) => !RETENTION_TERM_OWNERS.includes(file)) - .map(([file, source]) => ({ file, hooks: namedSymbols(source, PER_PAGE_RETENTION_HOOKS) })) - .filter(({ hooks }) => hooks.length > 0 && hooks.length < PER_PAGE_RETENTION_HOOKS.length) - .map(({ file, hooks }) => `${file}: only ${hooks.join(', ')}`) - .sort() - - expect( - partial, - 'A pane that threads some retention terms per page must thread all of them.' - ).toEqual([]) - }) - - // Keeps the symbol lists above from going stale: a term store that grows a new reader nobody - // classified would otherwise be a free way to hand-roll the OR-list. - it('classifies every value a term store exports', () => { - const classified = new Set([ - ...RETENTION_TERM_SYMBOLS, - ...PER_PAGE_RETENTION_HOOKS, - ...NON_READER_TERM_EXPORTS - ]) - const unclassified: string[] = [] - for (const file of RETENTION_TERM_STORES) { - const source = sources.get(file) - expect(source, `${file} is registered as a term store but no longer exists`).toBeDefined() - for (const [, name] of (source ?? '').matchAll(/^export (?:function|const|let) (\w+)/gm)) { - if (!classified.has(name)) { - unclassified.push(`${file}: ${name}`) - } - } - } - - expect( - unclassified, - 'A term store exports something this census does not classify. Add a reader to ' + - 'RETENTION_TERM_SYMBOLS (or PER_PAGE_RETENTION_HOOKS, for the per-page hooks typecheck ' + - 'already covers) so hand-rolled sites keep failing, and a writer to NON_READER_TERM_EXPORTS.' - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx index 80bd51508c7..08959ac271f 100644 --- a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx +++ b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx @@ -267,13 +267,9 @@ describe('restored client-hosted recovery window', () => { expect(noticeShown()).toBe(true) }) - // Why the number is asserted and not only derived from: every boundary case above stays green if - // the window shrinks to a millisecond, and a window shorter than one creation attempt would call - // the first batch of recoveries dead. The first batch is all this bounds — recovery runs four - // pages at a time and each awaits a create and then a navigate, so with 9+ restored tabs a page - // can see the notice while its turn has not come. Widening to the batched bound is ledgered. + // A window shorter than one creation attempt would call the first batch of recoveries dead, and + // every boundary case above stays green if the window shrinks to a millisecond. it('waits longer than the runtime spends creating one client page in the first batch', () => { - expect(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS).toBe(45_000) // Imported, not copied: a locally re-declared ceiling makes raising the runtime's own timeout // invisible here, which is the one change that could turn this window into a false verdict. expect(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS).toBeGreaterThan( diff --git a/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts b/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts index c147c01cc20..23cfa1ac548 100644 --- a/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts +++ b/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts @@ -2,22 +2,13 @@ import { describe, expect, it } from 'vitest' import { capPaletteSection, layoutMultiPrimaryPaletteSections, - orderMultiPrimaryPaletteItems, - PALETTE_SECTION_EXPAND_STEP, PALETTE_SECTION_RENDER_CAP, - softSplitPaletteSection, TYPED_QUERY_LEADING_PREVIEW, TYPED_QUERY_TRAILING_FLOOR } from './palette-section-render-cap' const range = (count: number): number[] => Array.from({ length: count }, (_, index) => index) -describe('PALETTE_SECTION_EXPAND_STEP', () => { - it('is configured to 20 entries per expansion step', () => { - expect(PALETTE_SECTION_EXPAND_STEP).toBe(20) - }) -}) - describe('capPaletteSection', () => { it('returns the original array reference when nothing overflows', () => { const items = range(PALETTE_SECTION_RENDER_CAP) @@ -61,33 +52,6 @@ describe('capPaletteSection', () => { }) }) -describe('softSplitPaletteSection', () => { - it('splits under the hard cap and reports everything after the preview', () => { - const split = softSplitPaletteSection(range(12), TYPED_QUERY_LEADING_PREVIEW) - - expect(split.preview).toEqual(range(6)) - expect(split.rest).toEqual([6, 7, 8, 9, 10, 11]) - expect(split.moreCount).toBe(6) - }) - - it('includes hard-cap overflow in moreCount without rendering it', () => { - const split = softSplitPaletteSection(range(80), TYPED_QUERY_LEADING_PREVIEW) - - expect(split.preview).toHaveLength(TYPED_QUERY_LEADING_PREVIEW) - expect(split.rest).toHaveLength(PALETTE_SECTION_RENDER_CAP - TYPED_QUERY_LEADING_PREVIEW) - expect(split.moreCount).toBe(80 - TYPED_QUERY_LEADING_PREVIEW) - }) - - it('keeps short sections intact with no remainder', () => { - const items = range(3) - const split = softSplitPaletteSection(items, TYPED_QUERY_LEADING_PREVIEW) - - expect(split.preview).toEqual(items) - expect(split.rest).toEqual([]) - expect(split.moreCount).toBe(0) - }) -}) - describe('layoutMultiPrimaryPaletteSections', () => { it('puts trailing floor before leading rest so worktrees stay early', () => { const layout = layoutMultiPrimaryPaletteSections({ @@ -160,16 +124,3 @@ describe('layoutMultiPrimaryPaletteSections', () => { expect(layout.leadingHardOverflowCount).toBe(10) }) }) - -describe('orderMultiPrimaryPaletteItems', () => { - it('interleaves floor before leading rest', () => { - const layout = layoutMultiPrimaryPaletteSections({ - leadingItems: range(8), - trailingItems: range(5).map((n) => n + 100) - }) - - expect(orderMultiPrimaryPaletteItems(layout)).toEqual([ - 0, 1, 2, 3, 4, 5, 100, 101, 102, 6, 7, 103, 104 - ]) - }) -}) diff --git a/src/renderer/src/components/cmd-j/palette-section-render-cap.ts b/src/renderer/src/components/cmd-j/palette-section-render-cap.ts index 29dee043a27..31441b11d01 100644 --- a/src/renderer/src/components/cmd-j/palette-section-render-cap.ts +++ b/src/renderer/src/components/cmd-j/palette-section-render-cap.ts @@ -56,13 +56,13 @@ export function capPaletteSection( * `moreCount` is everything after the preview (including rows past the hard cap) * so one overflow hint covers both scrollable rest and “keep typing” remainder. */ -export type SoftSplitSection = { +type SoftSplitSection = { preview: readonly T[] rest: readonly T[] moreCount: number } -export function softSplitPaletteSection( +function softSplitPaletteSection( items: readonly T[], previewCount: number, hardCap: number = PALETTE_SECTION_RENDER_CAP, diff --git a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts deleted file mode 100644 index 952cb21e23d..00000000000 --- a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @vitest-environment happy-dom -import { createRequire } from 'node:module' -import { createElement, type ReactNode } from 'react' -import { renderHook } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import * as contextModule from '@/components/confirmation-dialog-context' -import * as providerModule from '@/components/confirmation-dialog' -import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' -import { useConfirmationDialog } from '@/components/confirmation-dialog-context' - -// react-refresh ships no types; take the one binding this needs. -const { isLikelyComponentType } = createRequire(import.meta.url)('react-refresh/runtime') as { - isLikelyComponentType: (value: unknown) => boolean -} - -describe('confirmation dialog Fast Refresh boundary', () => { - it('exports nothing from the provider module that invalidates the refresh boundary', () => { - expect(Object.keys(providerModule)).toEqual(['ConfirmationDialogProvider']) - expect(isLikelyComponentType(providerModule.ConfirmationDialogProvider)).toBe(true) - }) - - it('keeps the context and hook in a component-free module', () => { - const components = Object.entries(contextModule) - .filter(([, value]) => isLikelyComponentType(value)) - .map(([name]) => name) - - expect(components).toEqual([]) - expect(typeof contextModule.useConfirmationDialog).toBe('function') - }) - - it('resolves the hook against the context the provider publishes', () => { - const wrapper = ({ children }: { children: ReactNode }) => - createElement(ConfirmationDialogProvider, null, children) - const { result } = renderHook(() => useConfirmationDialog(), { wrapper }) - - expect(typeof result.current).toBe('function') - }) -}) diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.test.ts b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts index e7940664e02..031690af7fe 100644 --- a/src/renderer/src/components/dictation/dictation-audio-meter.test.ts +++ b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts @@ -2,19 +2,10 @@ import { describe, expect, it } from 'vitest' import { analyzeDictationAudioChunk, createDictationMeterAnalyzerState, - measureDictationAudioChunk, toPublicDictationMeterState } from './dictation-audio-meter' describe('dictation audio meter', () => { - it('measures RMS and clamps peaks from copied audio samples', () => { - expect(measureDictationAudioChunk(new Float32Array([0.5, -0.5]))).toEqual({ - rms: 0.5, - peak: 0.5 - }) - expect(measureDictationAudioChunk(new Float32Array([2]))).toEqual({ rms: 2, peak: 1 }) - }) - it('uses a fast attack and slower release for a stable voice envelope', () => { const initial = createDictationMeterAnalyzerState() const loud = analyzeDictationAudioChunk(new Float32Array(128).fill(0.3), 100, initial) diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.ts b/src/renderer/src/components/dictation/dictation-audio-meter.ts index c7f9c98117a..6c3d9cf1349 100644 --- a/src/renderer/src/components/dictation/dictation-audio-meter.ts +++ b/src/renderer/src/components/dictation/dictation-audio-meter.ts @@ -36,7 +36,7 @@ export function createDictationMeterAnalyzerState(): DictationMeterAnalyzerState } } -export function measureDictationAudioChunk(samples: Float32Array): { +function measureDictationAudioChunk(samples: Float32Array): { rms: number peak: number } { diff --git a/src/renderer/src/components/dictation/microphone-devices.test.ts b/src/renderer/src/components/dictation/microphone-devices.test.ts index d449e758d3d..04ed38963e8 100644 --- a/src/renderer/src/components/dictation/microphone-devices.test.ts +++ b/src/renderer/src/components/dictation/microphone-devices.test.ts @@ -6,7 +6,6 @@ import { isMicrophoneDeviceConstraintError, listVoiceMicrophoneDevices, microphoneDeviceIdFromSelectValue, - microphoneSelectValueFromDeviceId, normalizeMicrophoneDeviceId, openMicrophoneCaptureStream, resolveMicrophoneDevice, @@ -288,7 +287,6 @@ describe('listVoiceMicrophoneDevices', () => { describe('microphone select values', () => { it('round-trips system default and concrete device ids', () => { - expect(microphoneSelectValueFromDeviceId(null)).toBe(SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE) expect(microphoneDeviceIdFromSelectValue(SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE)).toBeNull() expect(microphoneDeviceIdFromSelectValue('usb-1')).toBe('usb-1') }) diff --git a/src/renderer/src/components/dictation/microphone-devices.ts b/src/renderer/src/components/dictation/microphone-devices.ts index 9f97a6f286c..f27a89cae08 100644 --- a/src/renderer/src/components/dictation/microphone-devices.ts +++ b/src/renderer/src/components/dictation/microphone-devices.ts @@ -195,10 +195,6 @@ export async function openMicrophoneCaptureStream(args: { } } -export function microphoneSelectValueFromDeviceId(deviceId: string | null | undefined): string { - return normalizeMicrophoneDeviceId(deviceId) ?? SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE -} - export function microphoneDeviceIdFromSelectValue(value: string): string | null { if (value === SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE) { return null diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index e48ff20b325..a2697069df0 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -26,7 +26,7 @@ import { buildDiffEditorHideUnchangedOptions } from './diff-editor-hide-unchange import { useDiffEditorRegistration } from './diff-navigation-context' import { preserveDiffViewStateAcrossModelSwaps } from './diff-model-swap-view-state' import { monacoFindOptions } from './monaco-find-options' -import { resolveDocumentTheme } from '@/lib/document-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' export default function DiffViewer({ modelKey, @@ -50,6 +50,7 @@ export default function DiffViewer({ largeDiffSaveContentAvailable }: DiffViewerProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const isDark = useDocumentDarkTheme() const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const addDiffComment = useAppStore((s) => s.addDiffComment) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) @@ -294,7 +295,7 @@ export default function DiffViewer({ language={language} original={originalContent} modified={modifiedContent} - theme={resolveDocumentTheme(settings?.theme ?? 'light') ? 'vs-dark' : 'vs'} + theme={isDark ? 'vs-dark' : 'vs'} onMount={handleMount} // Why: key models by tab identity and preserve the modified undo stack across Changes-mode HEAD rotations. originalModelPath={currentDiffModelPaths.originalModelPath} diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index 6e6689395a6..228bf57f6e3 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -12,7 +12,7 @@ import { import type { IpynbCell } from './ipynb-parse' import { MarkdownPreviewBody } from './MarkdownPreviewBody' import { useMonacoColorizedLines } from './MonacoCodeExcerpt' -import { useDocumentDarkTheme } from './use-document-dark-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' const NO_MARKDOWN_COMPONENTS: Components = {} // Box metrics the preview and the live editor share, so activating a cell never shifts it. diff --git a/src/renderer/src/components/editor/IpynbCellOutputs.tsx b/src/renderer/src/components/editor/IpynbCellOutputs.tsx index 5397fede139..950e913b9c3 100644 --- a/src/renderer/src/components/editor/IpynbCellOutputs.tsx +++ b/src/renderer/src/components/editor/IpynbCellOutputs.tsx @@ -9,7 +9,7 @@ import { IpynbMarkdownCell } from './IpynbCellEditor' import { IpynbHtmlOutput } from './IpynbHtmlOutput' import { parseAnsiSegments, type AnsiColor } from './ipynb-ansi' import type { IpynbCell, IpynbOutput, IpynbOutputItem } from './ipynb-parse' -import { useDocumentDarkTheme } from './use-document-dark-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' const ANSI_PALETTE_KEYS = [ 'black', diff --git a/src/renderer/src/components/editor/IpynbHtmlOutput.tsx b/src/renderer/src/components/editor/IpynbHtmlOutput.tsx index 5ef92315121..2344f3a8504 100644 --- a/src/renderer/src/components/editor/IpynbHtmlOutput.tsx +++ b/src/renderer/src/components/editor/IpynbHtmlOutput.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import DOMPurify from 'dompurify' import { translate } from '@/i18n/i18n' -import { useDocumentDarkTheme } from './use-document-dark-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' // Why: an opaque-origin, script-free frame with a no-network CSP keeps output markup inert; // its links aim at popups the sandbox blocks. diff --git a/src/renderer/src/components/editor/IpynbRendering.test.tsx b/src/renderer/src/components/editor/IpynbRendering.test.tsx index 593104e2300..a3fd55808eb 100644 --- a/src/renderer/src/components/editor/IpynbRendering.test.tsx +++ b/src/renderer/src/components/editor/IpynbRendering.test.tsx @@ -11,7 +11,7 @@ vi.mock('@/i18n/i18n', () => ({ i18n: { language: 'en' }, translate: (_key: string, fallback: string) => fallback })) -vi.mock('./use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) +vi.mock('@/hooks/use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) vi.mock('@/lib/monaco-setup', () => ({ monaco: {} })) vi.mock('./MonacoCodeExcerpt', () => ({ useMonacoColorizedLines: () => [] diff --git a/src/renderer/src/components/editor/MermaidViewer.tsx b/src/renderer/src/components/editor/MermaidViewer.tsx index 722a8046a4a..97561212dc8 100644 --- a/src/renderer/src/components/editor/MermaidViewer.tsx +++ b/src/renderer/src/components/editor/MermaidViewer.tsx @@ -1,7 +1,7 @@ import React, { useLayoutEffect, useRef } from 'react' -import { useAppStore } from '@/store' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import MermaidBlock from './MermaidBlock' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' type MermaidViewerProps = { content: string @@ -17,10 +17,7 @@ export default function MermaidViewer({ filePath }: MermaidViewerProps): React.JSX.Element { const rootRef = useRef(null) - const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const isDark = useDocumentDarkTheme() // Why: Each viewing mode (source vs diagram) produces different DOM heights. // Mode-scoped keys prevent restoring a source-mode scroll position in diagram diff --git a/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx b/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx index 9bd60e16948..1e2b31eb6ee 100644 --- a/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx +++ b/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx @@ -3,7 +3,7 @@ import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import { useDocumentDarkTheme } from './use-document-dark-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' let pythonLanguageRegistrationPromise: Promise | null = null diff --git a/src/renderer/src/components/editor/MonacoEditor.system-theme.test.tsx b/src/renderer/src/components/editor/MonacoEditor.system-theme.test.tsx new file mode 100644 index 00000000000..9f1065591a4 --- /dev/null +++ b/src/renderer/src/components/editor/MonacoEditor.system-theme.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resetSystemPrefersDarkSubscriptionForTests } from '@/components/terminal-pane/use-system-prefers-dark' + +const editorProps: { current: Record | null } = vi.hoisted(() => ({ + current: null +})) +const settingsState: { theme: 'system' | 'dark' | 'light' } = vi.hoisted(() => ({ + theme: 'system' +})) + +vi.mock('@monaco-editor/react', () => ({ + default: (props: Record) => { + editorProps.current = props + return null + }, + loader: { config: vi.fn() } +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + settings: { + get theme() { + return settingsState.theme + }, + terminalFontSize: 13, + terminalFontFamily: 'monospace' + }, + editorFontZoomLevel: 0, + setPendingEditorReveal: vi.fn(), + setEditorCursorLine: vi.fn(), + addDiffComment: vi.fn(), + deleteDiffComment: vi.fn(), + updateDiffComment: vi.fn(), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: vi.fn(), + worktreeDiffComments: {} + }) +})) +vi.mock('../diff-comments/useDiffCommentDecorator', () => ({ + useDiffCommentDecorator: vi.fn() +})) +vi.mock('./useContextualCopySetup', () => ({ + useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null }) +})) + +import MonacoEditor from './MonacoEditor' + +function installMatchMedia(initialMatches: boolean): { + emit: (matches: boolean) => void +} { + let matches = initialMatches + const listeners = new Set() + const mediaQuery = '(prefers-color-scheme: dark)' + const media: MediaQueryList = { + get matches() { + return matches + }, + media: mediaQuery, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener(type, listener) { + if (type === 'change' && typeof listener === 'function') { + listeners.add(listener) + } + }, + removeEventListener(type, listener) { + if (type === 'change' && typeof listener === 'function') { + listeners.delete(listener) + } + }, + dispatchEvent() { + return false + } + } + window.matchMedia = () => media + return { + emit(nextMatches: boolean): void { + matches = nextMatches + const event = new MediaQueryListEvent('change', { matches: nextMatches, media: mediaQuery }) + for (const listener of listeners) { + listener(event) + } + } + } +} + +function renderEditor(): void { + render( + + ) +} + +const originalMatchMedia = window.matchMedia + +afterEach(() => { + cleanup() + editorProps.current = null + settingsState.theme = 'system' + resetSystemPrefersDarkSubscriptionForTests() + window.matchMedia = originalMatchMedia +}) + +describe('MonacoEditor system theme', () => { + it('follows a system color-scheme change while the editor stays mounted', () => { + const media = installMatchMedia(false) + renderEditor() + + expect(editorProps.current?.theme).toBe('vs') + + act(() => { + media.emit(true) + }) + expect(editorProps.current?.theme).toBe('vs-dark') + + act(() => { + media.emit(false) + }) + expect(editorProps.current?.theme).toBe('vs') + }) + + it('keeps an explicit theme when the system color scheme changes', () => { + settingsState.theme = 'light' + const media = installMatchMedia(true) + renderEditor() + + expect(editorProps.current?.theme).toBe('vs') + + act(() => { + media.emit(false) + }) + expect(editorProps.current?.theme).toBe('vs') + }) +}) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index cb3e9693f47..17f659607a3 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -21,6 +21,7 @@ import { useMonacoContentSyncBridge } from './use-monaco-content-sync-bridge' import { useMonacoMarkdownAnnotations } from './use-monaco-markdown-annotations' import { useMonacoEditorDecorations } from './use-monaco-editor-decorations' import { useMonacoEditorMount } from './use-monaco-editor-mount' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import { snapshotMonacoViewState } from './monaco-view-state-persistence' import { MonacoMarkdownAnnotationOverlay } from './MonacoMarkdownAnnotationOverlay' @@ -117,9 +118,7 @@ export default function MonacoEditor({ const [gutterMenuOpen, setGutterMenuOpen] = useState(false) const [gutterMenuPoint, setGutterMenuPoint] = useState({ x: 0, y: 0 }) const [gutterMenuLine, setGutterMenuLine] = useState(1) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const isDark = useDocumentDarkTheme() const { queueReveal, cancelScheduledReveal, clearTransientRevealHighlight } = useMonacoRevealScheduler() diff --git a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx index 0e7c53068fa..dc358e1973a 100644 --- a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx +++ b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx @@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next' import { NodeViewContent, NodeViewWrapper } from '@tiptap/react' import type { NodeViewProps } from '@tiptap/react' import { Copy, Check } from 'lucide-react' -import { useAppStore } from '@/store' import MermaidBlock from './MermaidBlock' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import { translate } from '@/i18n/i18n' import { getCodeBlockLanguageLabel, @@ -28,10 +28,7 @@ export function RichMarkdownCodeBlock({ // Why: clipboard IPC can resolve after the node view unmounts; avoid // starting a reset timer that will outlive the component. const isMountedRef = useRef(false) - const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const isDark = useDocumentDarkTheme() const isMermaid = language === 'mermaid' diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 449f89cb70f..a50dcce43df 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -38,6 +38,7 @@ import { import { useCombinedDiffNotesActions } from './review-controls/use-combined-diff-notes-actions' import { useCombinedDiffSectionActions } from './review-controls/use-combined-diff-section-actions' import { useCombinedDiffViewPreferences } from './review-controls/use-combined-diff-view-preferences' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' export default function CombinedDiffViewer({ file, @@ -64,9 +65,7 @@ export default function CombinedDiffViewer({ ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId]) const canOpenWorkspaceFileBrowserForPath = useWorkspaceFileBrowserActionPredicate(file.worktreeId) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const isDark = useDocumentDarkTheme() const [sections, setSections] = useState([]) const [sectionHeights, setSectionHeights] = useState>({}) diff --git a/src/renderer/src/components/editor/discard-editor-file-changes.ts b/src/renderer/src/components/editor/discard-editor-file-changes.ts new file mode 100644 index 00000000000..bc77df8b2f0 --- /dev/null +++ b/src/renderer/src/components/editor/discard-editor-file-changes.ts @@ -0,0 +1,17 @@ +import { useAppStore } from '@/store' +import { requestEditorSaveQuiesce } from './editor-autosave' + +/** "Don't Save": cancel pending saves, drop unsaved edits, then close the tab. */ +export async function discardEditorFileChangesAndClose(fileId: string): Promise { + // Why: "Don't Save" must win over any pending autosave write for the same tab. + try { + await requestEditorSaveQuiesce({ fileId }) + } catch (error) { + console.warn('Autosave quiesce failed before discard', error) + } + const state = useAppStore.getState() + state.markFileDirty(fileId, false) + // Why: a leftover draft makes closeFile keep a never-saved untitled placeholder on disk. + state.clearEditorDraft(fileId) + state.closeFile(fileId) +} diff --git a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts index f159591496c..3843b3d6a4c 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts @@ -5,6 +5,39 @@ import { vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' import type { AppState } from '@/store' +import { makeWorktree } from '@/store/slices/store-test-helpers' + +export type FakeEditorDisk = { + files: Map + fs: { + writeFile: ReturnType + deletePath: ReturnType + stat: ReturnType + } +} + +/** In-memory stand-in for window.api.fs, so tests assert what is actually on disk. */ +export function createFakeEditorDisk(initialFiles: Record = {}): FakeEditorDisk { + const files = new Map(Object.entries(initialFiles)) + return { + files, + fs: { + writeFile: vi.fn(async ({ filePath, content }: { filePath: string; content: string }) => { + files.set(filePath, content) + }), + deletePath: vi.fn(async ({ targetPath }: { targetPath: string }) => { + files.delete(targetPath) + }), + stat: vi.fn(async ({ filePath }: { filePath: string }) => { + const content = files.get(filePath) + if (content === undefined) { + throw new Error(`ENOENT: no such file ${filePath}`) + } + return { size: content.length, isDirectory: false, mtime: 0 } + }) + } + } +} export type EditorWindowStub = { addEventListener: Window['addEventListener'] @@ -12,17 +45,13 @@ export type EditorWindowStub = { dispatchEvent: Window['dispatchEvent'] setTimeout: Window['setTimeout'] clearTimeout: Window['clearTimeout'] - api: { - fs: { - writeFile: ReturnType - } - } + api: { fs: FakeEditorDisk['fs'] } } -/** Stubs the global window with an isolated event target and fs bridge; - * returns the writeFile mock for assertions. */ -export function stubEditorWindow(): ReturnType { - const writeFile = vi.fn().mockResolvedValue(undefined) +/** Stubs the global window with an isolated event target backed by `disk`. */ +export function stubEditorWindowWithDisk( + disk: FakeEditorDisk = createFakeEditorDisk() +): FakeEditorDisk { const eventTarget = new EventTarget() vi.stubGlobal('window', { addEventListener: eventTarget.addEventListener.bind(eventTarget), @@ -30,13 +59,15 @@ export function stubEditorWindow(): ReturnType { dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), setTimeout: globalThis.setTimeout.bind(globalThis), clearTimeout: globalThis.clearTimeout.bind(globalThis), - api: { - fs: { - writeFile - } - } + api: { fs: disk.fs } } satisfies EditorWindowStub) - return writeFile + return disk +} + +/** Stubs the global window with an isolated event target and fs bridge; + * returns the writeFile mock for assertions. */ +export function stubEditorWindow(): ReturnType { + return stubEditorWindowWithDisk().fs.writeFile } export function createEditorStore(): StoreApi { @@ -60,3 +91,26 @@ export function createEditorStore(): StoreApi { ...createEditorSlice(...(args as Parameters)) })) as unknown as StoreApi } + +/** Store with a local worktree at /repo and an open, untouched untitled note at /repo/`fileName`. */ +export function createUntitledNoteStore(fileName: string): StoreApi { + const store = createEditorStore() + store.setState({ + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' })] + }, + browserTabsByWorktree: {}, + tabsByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + unifiedTabsByWorktree: {} + }) + store.getState().openFile({ + filePath: `/repo/${fileName}`, + relativePath: fileName, + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isUntitled: true + }) + return store +} diff --git a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx new file mode 100644 index 00000000000..4d55dda3d55 --- /dev/null +++ b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import type { StoreApi } from 'zustand/vanilla' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { + createFakeEditorDisk, + createUntitledNoteStore, + type FakeEditorDisk +} from './editor-autosave-controller-test-fixture' +import { getDiskBaselineSignature } from './diff-content-signature' +import { useTerminalSaveDialog } from '../terminal/useTerminalSaveDialog' +import { + useTerminalEditorCloseDialogActions, + type TerminalEditorCloseDialogActionsInput +} from '../use-terminal-editor-close-dialog-actions' + +const storeHolder = vi.hoisted((): { store: StoreApi | null } => ({ store: null })) + +function requireStore(): StoreApi { + if (!storeHolder.store) { + throw new Error('test store not initialised') + } + return storeHolder.store +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => requireStore().getState() } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: vi.fn() })) + +const FILE_ID = '/repo/untitled.md' + +function mainWindowDialogController(fileId: string): TerminalEditorCloseDialogActionsInput { + return { + advanceEditorCloseQueue: vi.fn(), + inFlightSaveFileIdRef: { current: null }, + isClosingRef: { current: false }, + pendingEditorCloseQueueRef: { current: [fileId] }, + queueEditorCloseRequests: vi.fn(), + releaseCloseDialogGuardAfterDebounce: vi.fn(), + saveDialogFileId: fileId, + setSaveDialogFileId: vi.fn(), + waitForFileClosed: vi.fn(async () => true), + windowCloseAfterDirtyRef: { current: null } + } +} + +describe("Don't Save in the unsaved-changes dialogs on a never-saved untitled note", () => { + let disk: FakeEditorDisk + let store: StoreApi + + beforeEach(() => { + disk = createFakeEditorDisk({ [FILE_ID]: '' }) + Object.assign(window, { api: { fs: disk.fs } }) + store = createUntitledNoteStore('untitled.md') + storeHolder.store = store + store.getState().setLastKnownDiskSignature(FILE_ID, getDiskBaselineSignature('')) + store.getState().setEditorDraft(FILE_ID, 'typed but never saved') + store.getState().markFileDirty(FILE_ID, true) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') + storeHolder.store = null + }) + + it('main window removes the empty placeholder', async () => { + const { result } = renderHook(() => + useTerminalEditorCloseDialogActions(mainWindowDialogController(FILE_ID)) + ) + + await act(() => result.current.handleSaveDialogDiscard()) + + expect(store.getState().openFiles).toHaveLength(0) + await vi.waitFor(() => expect(disk.files.has(FILE_ID)).toBe(false)) + }) + + it('floating panel removes the empty placeholder', async () => { + const { result } = renderHook(() => + useTerminalSaveDialog({ + openFiles: store.getState().openFiles, + closeFile: store.getState().closeFile + }) + ) + act(() => result.current.requestCloseFile(FILE_ID)) + expect(result.current.saveDialogFileId).toBe(FILE_ID) + + await act(async () => { + // Why: the hook's result type declares the discard handler as void though it returns a promise. + await Promise.resolve(result.current.handleSaveDialogDiscard()) + }) + + expect(store.getState().openFiles).toHaveLength(0) + await vi.waitFor(() => expect(disk.files.has(FILE_ID)).toBe(false)) + }) +}) diff --git a/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts b/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts new file mode 100644 index 00000000000..a2fb9ba554b --- /dev/null +++ b/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts @@ -0,0 +1,191 @@ +import type { StoreApi } from 'zustand/vanilla' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { ORCA_EDITOR_SAVE_AND_CLOSE_EVENT } from './editor-autosave' +import { attachEditorAutosaveController } from './editor-autosave-controller' +import { + createFakeEditorDisk, + createUntitledNoteStore, + stubEditorWindowWithDisk, + type FakeEditorDisk +} from './editor-autosave-controller-test-fixture' +import { discardEditorFileChangesAndClose } from './discard-editor-file-changes' +import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry' +import { getDiskBaselineSignature } from './diff-content-signature' + +const storeHolder = vi.hoisted((): { store: StoreApi | null } => ({ store: null })) + +function requireStore(): StoreApi { + if (!storeHolder.store) { + throw new Error('test store not initialised') + } + return storeHolder.store +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => requireStore().getState() } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: vi.fn() })) + +const FILE_ID = '/repo/untitled.md' + +function typeInto(store: StoreApi, content: string): void { + store.getState().setEditorDraft(FILE_ID, content) + store.getState().markFileDirty(FILE_ID, true) +} + +/** What the editor panel records after a clean (re)load of the tab shows `content` from disk. */ +function loadFromDisk(store: StoreApi, content: string): void { + store.getState().setLastKnownDiskSignature(FILE_ID, getDiskBaselineSignature(content)) +} + +function isReopenable(store: StoreApi): boolean { + return (store.getState().recentlyClosedEditorTabsByWorktree['wt-1'] ?? []).some( + (tab) => tab.filePath === FILE_ID + ) +} + +describe('untitled note save lifecycle', () => { + let disk: FakeEditorDisk + let store: StoreApi + + beforeEach(() => { + // Why: the window stub binds setTimeout, so fake timers must be installed first for autosave to be drivable. + vi.useFakeTimers() + disk = stubEditorWindowWithDisk(createFakeEditorDisk({ [FILE_ID]: '' })) + store = createUntitledNoteStore('untitled.md') + storeHolder.store = store + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + __clearSelfWriteRegistryForTests() + storeHolder.store = null + }) + + it.each([ + ['before its empty content loads', (): void => {}], + ['after its empty content loads', (): void => loadFromDisk(store, '')] + ])('still deletes an untouched untitled note closed %s', async (_when, load) => { + load() + + store.getState().closeFile(FILE_ID) + + await vi.waitFor(() => expect(disk.files.has(FILE_ID)).toBe(false)) + expect(isReopenable(store)).toBe(false) + }) + + it.each([ + [ + 'Save in the unsaved-changes prompt', + async (): Promise => { + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, { detail: { fileId: FILE_ID } }) + ) + await vi.waitFor(() => expect(store.getState().openFiles).toHaveLength(0)) + } + ], + [ + 'autosave, then a plain close', + async (): Promise => { + await vi.advanceTimersByTimeAsync(1500) + store.getState().closeFile(FILE_ID) + } + ] + ])('keeps the typed note after %s', async (_route, saveAndClose) => { + loadFromDisk(store, '') + const cleanup = attachEditorAutosaveController(store) + try { + typeInto(store, 'my note') + + await saveAndClose() + // Why: drain any stat → delete chain the close scheduled before asserting the note survived. + await vi.advanceTimersByTimeAsync(0) + + expect(store.getState().openFiles).toHaveLength(0) + expect(disk.files.get(FILE_ID)).toBe('my note') + expect(disk.fs.deletePath).not.toHaveBeenCalled() + expect(isReopenable(store)).toBe(true) + } finally { + cleanup() + } + }) + + it('deletes the note when its last save emptied it', async () => { + const cleanup = attachEditorAutosaveController(store) + try { + typeInto(store, 'a') + await vi.advanceTimersByTimeAsync(1500) + typeInto(store, '') + await vi.advanceTimersByTimeAsync(1500) + expect(disk.files.get(FILE_ID)).toBe('') + + store.getState().closeFile(FILE_ID) + + await vi.waitFor(() => expect(disk.files.has(FILE_ID)).toBe(false)) + } finally { + cleanup() + } + }) + + it("keeps autosaved content when Don't Save interrupts the in-flight write", async () => { + loadFromDisk(store, '') + let finishWrite: () => void = () => {} + disk.fs.writeFile.mockImplementationOnce( + ({ filePath, content }: { filePath: string; content: string }) => + new Promise((resolve) => { + finishWrite = () => { + disk.files.set(filePath, content) + resolve() + } + }) + ) + const cleanup = attachEditorAutosaveController(store) + try { + typeInto(store, 'my note') + await vi.advanceTimersByTimeAsync(1500) + expect(disk.fs.writeFile).toHaveBeenCalledTimes(1) + + const discarded = discardEditorFileChangesAndClose(FILE_ID) + finishWrite() + await discarded + // Why: the superseded save left the tab looking like a placeholder, so only the size check keeps the note. + await vi.advanceTimersByTimeAsync(0) + + expect(store.getState().openFiles).toHaveLength(0) + expect(disk.files.get(FILE_ID)).toBe('my note') + expect(disk.fs.deletePath).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + + it.each([ + ['while its tab showed the new text', true], + ['while its tab was in the background', false] + ])('keeps a note an agent wrote into %s', async (_when, reloaded) => { + loadFromDisk(store, '') + disk.files.set(FILE_ID, 'agent text') + if (reloaded) { + loadFromDisk(store, 'agent text') + } + + store.getState().closeFile(FILE_ID) + + // Why: a macrotask drains the stat → delete chain before asserting nothing was removed. + await vi.advanceTimersByTimeAsync(0) + expect(disk.files.get(FILE_ID)).toBe('agent text') + expect(disk.fs.deletePath).not.toHaveBeenCalled() + // Why: a background tab never reloaded the write, so only the size check knew it was a real note. + expect(isReopenable(store)).toBe(reloaded) + }) + + it("removes a never-saved note's placeholder on Don't Save", async () => { + loadFromDisk(store, '') + typeInto(store, 'typed but never saved') + + await discardEditorFileChangesAndClose(FILE_ID) + + expect(store.getState().openFiles).toHaveLength(0) + await vi.waitFor(() => expect(disk.files.has(FILE_ID)).toBe(false)) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-find-widget.test.ts b/src/renderer/src/components/editor/monaco-find-widget.test.ts index 04be99b464a..6e64645c159 100644 --- a/src/renderer/src/components/editor/monaco-find-widget.test.ts +++ b/src/renderer/src/components/editor/monaco-find-widget.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from 'vitest' import { closeMonacoFindWidget, closeUnfocusedMonacoFindOrPreventDialogDismiss, - isMonacoFindHostFocused, isMonacoFindWidgetOpen } from './monaco-find-widget' @@ -56,18 +55,6 @@ describe('isMonacoFindWidgetOpen', () => { }) }) -describe('isMonacoFindHostFocused', () => { - it('is true only when the event target is inside the host', () => { - const host = document.createElement('div') - const inside = document.createElement('input') - const outside = document.createElement('input') - host.append(inside) - expect(isMonacoFindHostFocused(host, inside)).toBe(true) - expect(isMonacoFindHostFocused(host, outside)).toBe(false) - expect(isMonacoFindHostFocused(null, inside)).toBe(false) - }) -}) - describe('closeMonacoFindWidget', () => { it('clicks the close control on an open widget', () => { const root = document.createElement('div') diff --git a/src/renderer/src/components/editor/monaco-find-widget.ts b/src/renderer/src/components/editor/monaco-find-widget.ts index 5a2daf24f3c..278953edbce 100644 --- a/src/renderer/src/components/editor/monaco-find-widget.ts +++ b/src/renderer/src/components/editor/monaco-find-widget.ts @@ -16,7 +16,7 @@ export function isMonacoFindWidgetOpen(root: ParentNode | null | undefined): boo return getOpenMonacoFindWidget(root) !== null } -export function isMonacoFindHostFocused( +function isMonacoFindHostFocused( root: ParentNode | null | undefined, eventTarget: EventTarget | null | undefined ): boolean { diff --git a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts index ca6f4c023f2..380063c653f 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts @@ -13,6 +13,7 @@ import { getMarkdownPreviewSourceRelativePath, resolveMarkdownPreviewSourceWorktree } from './markdown-preview-source-routing' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' export function useMarkdownPreviewSourceFoundation({ @@ -143,9 +144,7 @@ export function useMarkdownPreviewSourceFoundation({ ) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const editorFontSize = computeEditorFontSize(14, editorFontZoomLevel) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const isDark = useDocumentDarkTheme() const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef) diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuide.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuide.tsx index df764f8f69d..d99f5713e3d 100644 --- a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuide.tsx +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuide.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { ChevronDown, ChevronUp } from 'lucide-react' -import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { Button } from '../ui/button' import { MobileEmulatorAgentSetupGuideSteps } from './MobileEmulatorAgentSetupGuideSteps' @@ -59,8 +58,8 @@ export function MobileEmulatorAgentSetupGuide({ )}{' '} {translate( - 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.72736b051f', - 'Set up Orca CLI + skill when you want agents to drive this simulator.' + 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuide.installSkillPrompt', + 'Install the Orca CLI skill when you want agents to drive this simulator.' )} )} @@ -120,18 +119,6 @@ export function MobileEmulatorAgentSetupGuide({ {expanded && !setup.setupComplete ? (
-
- - {setup.completedCount}/2 - -
- - - {!setup.cliSupported && !setup.cliLoading && setup.cliInstallStatus?.detail ? ( - - {setup.cliInstallStatus.detail} - - ) : null} - - -
- -
-
- -
-
-

- {translate( - 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.21f5687c07', - 'Orca CLI skill' - )} -

- { - recordFeatureInteraction('mobile-emulator-agent-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() - }} - onRecheck={() => { - recordFeatureInteraction('mobile-emulator-agent-setup') - void setup.recheckSetup() - }} - /> -
-
+
+

+ {translate( + 'auto.components.emulator.pane.MobileEmulatorAgentSetupGuideSteps.21f5687c07', + 'Orca CLI skill' + )} +

+ { + recordFeatureInteraction('mobile-emulator-agent-setup') + }} + onRecheck={() => { + recordFeatureInteraction('mobile-emulator-agent-setup') + void setup.recheckSetup() + }} + />
) } diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts deleted file mode 100644 index 2bb986b6291..00000000000 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import { - getMobileEmulatorCliPathNeedsAttention, - getMobileEmulatorCliStepBadgeState, - shouldShowMobileEmulatorSkillPreInstallNotice -} from './mobile-emulator-agent-setup-cli-state' - -function cliStatus(overrides: Partial = {}): CliInstallStatus { - return { - platform: 'darwin', - commandName: 'orca', - commandPath: '/usr/local/bin/orca', - pathDirectory: '/usr/local/bin', - pathConfigured: true, - launcherPath: '/Applications/Orca.app/Contents/MacOS/orca', - installMethod: 'symlink', - supported: true, - state: 'installed', - currentTarget: null, - unsupportedReason: null, - detail: null, - ...overrides - } -} - -describe('getMobileEmulatorCliPathNeedsAttention', () => { - it('flags installed CLIs that are not visible on PATH yet', () => { - expect(getMobileEmulatorCliPathNeedsAttention(cliStatus({ pathConfigured: false }))).toBe(true) - expect(getMobileEmulatorCliPathNeedsAttention(cliStatus())).toBe(false) - expect(getMobileEmulatorCliPathNeedsAttention(cliStatus({ state: 'not_installed' }))).toBe( - false - ) - }) -}) - -describe('getMobileEmulatorCliStepBadgeState', () => { - it('marks enabled CLIs as done', () => { - expect( - getMobileEmulatorCliStepBadgeState({ - cliBusy: false, - cliEnabled: true, - cliPathNeedsAttention: false - }) - ).toBe('done') - }) - - it('marks PATH-fix and registration flows as in progress', () => { - expect( - getMobileEmulatorCliStepBadgeState({ - cliBusy: true, - cliEnabled: false, - cliPathNeedsAttention: false - }) - ).toBe('in-progress') - expect( - getMobileEmulatorCliStepBadgeState({ - cliBusy: false, - cliEnabled: false, - cliPathNeedsAttention: true - }) - ).toBe('in-progress') - }) -}) - -describe('shouldShowMobileEmulatorSkillPreInstallNotice', () => { - it('hides the prereq notice once either step is already complete', () => { - expect( - shouldShowMobileEmulatorSkillPreInstallNotice({ - cliEnabled: true, - cliSkillInstalled: false - }) - ).toBe(false) - expect( - shouldShowMobileEmulatorSkillPreInstallNotice({ - cliEnabled: false, - cliSkillInstalled: true - }) - ).toBe(false) - expect( - shouldShowMobileEmulatorSkillPreInstallNotice({ - cliEnabled: false, - cliSkillInstalled: false - }) - ).toBe(true) - }) -}) diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts deleted file mode 100644 index 4e350eaf0a4..00000000000 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { StepState } from '../settings/SetupStepBadge' - -export function getMobileEmulatorCliPathNeedsAttention(status: CliInstallStatus | null): boolean { - return status?.state === 'installed' && status.pathConfigured === false -} - -export function getMobileEmulatorCliStepBadgeState(input: { - cliBusy: boolean - cliEnabled: boolean - cliPathNeedsAttention: boolean -}): StepState { - if (input.cliEnabled) { - return 'done' - } - if (input.cliBusy || input.cliPathNeedsAttention) { - return 'in-progress' - } - return 'pending' -} - -export function shouldShowMobileEmulatorSkillPreInstallNotice(input: { - cliEnabled: boolean - cliSkillInstalled: boolean -}): boolean { - // Why: an installed skill should not reopen with "Install" just because CLI - // probes are stale; only gate first-time setup on CLI availability. - return !input.cliSkillInstalled && !input.cliEnabled -} diff --git a/src/renderer/src/components/emulator-pane/use-mobile-emulator-agent-setup-state.ts b/src/renderer/src/components/emulator-pane/use-mobile-emulator-agent-setup-state.ts index 2970bdad007..dae1b70f0e4 100644 --- a/src/renderer/src/components/emulator-pane/use-mobile-emulator-agent-setup-state.ts +++ b/src/renderer/src/components/emulator-pane/use-mobile-emulator-agent-setup-state.ts @@ -1,67 +1,24 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' -import type { CliInstallStatus } from '../../../../shared/cli-install-types' import { ORCA_CLI_SKILL_NAME } from '@/lib/agent-feature-install-commands' -import { - ensureOrcaCliAvailableForAgentSkillTerminal, - isOrcaCliAvailableOnPath -} from '@/lib/agent-skill-cli-prerequisite' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useMountedRef } from '@/hooks/useMountedRef' -import { getMobileEmulatorCliPathNeedsAttention } from './mobile-emulator-agent-setup-cli-state' import { translate } from '@/i18n/i18n' -function getCliActionLabel(status: CliInstallStatus | null, busy: boolean): string { - if (busy) { - return translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.fdcca1ec75', - 'Registering...' - ) - } - if (isOrcaCliAvailableOnPath(status)) { - return translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.69fb2c2289', - 'Enabled' - ) - } - if (status?.state === 'installed') { - return translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.c6705092ba', - 'Fix PATH' - ) - } - return translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.7c1b6bdb1e', - 'Enable' - ) -} - +// Why: Orca terminals already provide the `orca` command, so agent control only needs the skill. export function useMobileEmulatorAgentSetupState(enabled = true): { - cliActionLabel: string - cliBusy: boolean - cliEnabled: boolean - cliInstallStatus: CliInstallStatus | null - cliPathNeedsAttention: boolean - cliLoading: boolean cliSkillError: string | null cliSkillInstalled: boolean cliSkillLoading: boolean - cliSupported: boolean - completedCount: number - handleEnableCli: () => Promise recheckSetup: () => Promise refreshCliSkill: () => Promise setupComplete: boolean setupRechecking: boolean statusReady: boolean - step2Blocked: boolean } { - const [cliInstallStatus, setCliInstallStatus] = useState(null) - const [cliLoading, setCliLoading] = useState(true) - const [cliBusy, setCliBusy] = useState(false) const [setupRechecking, setSetupRechecking] = useState(false) const mountedRef = useMountedRef() const { @@ -74,60 +31,17 @@ export function useMobileEmulatorAgentSetupState(enabled = true): { sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const refreshCliStatus = useCallback(async (): Promise => { - setCliLoading(true) - try { - const status = await window.api.cli.getInstallStatus() - if (mountedRef.current) { - setCliInstallStatus(status) - } - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.51074ccb05', - 'Failed to load CLI status.' - ) - ) - setCliInstallStatus(null) - } - } finally { - if (mountedRef.current) { - setCliLoading(false) - } - } - }, [mountedRef]) - useEffect(() => { if (!enabled) { return } - void refreshCliStatus() - }, [enabled, refreshCliStatus]) - - useEffect(() => { - if (!enabled) { - return - } - // Why: users often register the CLI from Settings first; refresh on focus so - // the emulator guide reflects the latest install/PATH state. + // Why: users often install the skill from Settings or a terminal; refresh on focus. const handleFocus = (): void => { - void refreshCliStatus() void refreshCliSkill() } window.addEventListener('focus', handleFocus) return () => window.removeEventListener('focus', handleFocus) - }, [enabled, refreshCliSkill, refreshCliStatus]) - - const cliEnabled = isOrcaCliAvailableOnPath(cliInstallStatus) - const cliPathNeedsAttention = getMobileEmulatorCliPathNeedsAttention(cliInstallStatus) - const cliSupported = cliInstallStatus?.supported ?? false - const completedCount = [cliEnabled, cliSkillInstalled].filter(Boolean).length - const step2Blocked = !cliEnabled && !cliSkillInstalled - const setupComplete = cliEnabled && cliSkillInstalled - const statusReady = !cliLoading && !cliSkillLoading + }, [enabled, refreshCliSkill]) const recheckSetup = useCallback(async (): Promise => { if (setupRechecking) { @@ -135,18 +49,11 @@ export function useMobileEmulatorAgentSetupState(enabled = true): { } setSetupRechecking(true) try { - const [cliStatus, skillInstalled] = await Promise.all([ - window.api.cli.getInstallStatus(), - refreshCliSkill() - ]) - if (mountedRef.current) { - setCliInstallStatus(cliStatus) - } - const cliReady = isOrcaCliAvailableOnPath(cliStatus) + const skillInstalled = await refreshCliSkill() if (!mountedRef.current) { return } - if (cliReady && skillInstalled) { + if (skillInstalled) { toast.success( translate( 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.35dea1ae12', @@ -155,28 +62,10 @@ export function useMobileEmulatorAgentSetupState(enabled = true): { ) return } - if (skillInstalled) { - toast.message( - translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.9dff3a6338', - 'Skill is installed. Enable the Orca CLI to finish setup.' - ) - ) - return - } - if (cliReady) { - toast.message( - translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.15986a1080', - 'Orca CLI is ready. Install the skill to finish setup.' - ) - ) - return - } toast.message( translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.4c26913def', - 'Still not set up. Complete both steps to enable agent control.' + 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.skillNotInstalled', + 'The Orca CLI skill is not installed yet.' ) ) } catch (error) { @@ -197,45 +86,14 @@ export function useMobileEmulatorAgentSetupState(enabled = true): { } }, [mountedRef, refreshCliSkill, setupRechecking]) - const handleEnableCli = useCallback(async (): Promise => { - setCliBusy(true) - try { - const next = await ensureOrcaCliAvailableForAgentSkillTerminal({ - onStatusChange: setCliInstallStatus - }) - if (mountedRef.current && isOrcaCliAvailableOnPath(next)) { - toast.success( - translate( - 'auto.components.emulator.pane.use.mobile.emulator.agent.setup.state.2b519eed94', - 'Registered the Orca CLI in PATH.' - ) - ) - } - } finally { - if (mountedRef.current) { - setCliBusy(false) - } - } - }, [mountedRef]) - return { - cliActionLabel: getCliActionLabel(cliInstallStatus, cliBusy), - cliBusy, - cliEnabled, - cliInstallStatus, - cliPathNeedsAttention, - cliLoading, cliSkillError, cliSkillInstalled, cliSkillLoading, - cliSupported, - completedCount, - handleEnableCli, recheckSetup, refreshCliSkill, - setupComplete, + setupComplete: cliSkillInstalled, setupRechecking, - statusReady, - step2Blocked + statusReady: !cliSkillLoading } } diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts deleted file mode 100644 index 4a02840098e..00000000000 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname - -function componentSource(relativePath: string): string { - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('feature interaction writer boundaries', () => { - it('keeps Cmd+J feature writers in open/selection handlers, not query or navigation rendering', () => { - // Selection and create callbacks now live in focused hooks; the surface only renders them. - const selectionSource = componentSource('use-worktree-jump-palette-selection-actions.ts') - const lifecycleSource = componentSource('use-worktree-jump-palette-selection-lifecycle.ts') - const createSource = componentSource('use-worktree-jump-palette-create-action.ts') - const handlerSection = [selectionSource, lifecycleSource, createSource].join('\n') - const renderSection = componentSource('worktree-jump-palette-surface.tsx') - - const cmdJWriterPattern = /recordFeatureInteraction\('cmd-j/g - const allCmdJWriterCount = handlerSection.match(cmdJWriterPattern)?.length ?? 0 - expect(allCmdJWriterCount).toBeGreaterThanOrEqual(6) - expect(handlerSection.match(cmdJWriterPattern)?.length ?? 0).toBe(allCmdJWriterCount) - expect(renderSection).not.toContain("recordFeatureInteraction('cmd-j") - expect( - sourceBetween(lifecycleSource, 'const handleQueryChange', 'const cancelFallbackFocusFrames') - ).not.toContain("recordFeatureInteraction('cmd-j") - }) - - it('keeps task-provider writers off filters, tab switches, query edits, refresh, and pagination', () => { - const providerWriter = /recordFeatureInteraction\('(github|gitlab|linear)-tasks'\)/ - const refreshSource = componentSource('use-task-page-github-cache-reconciliation.ts') - const paginationSource = componentSource('use-task-page-github-search-pagination.ts') - const searchSource = componentSource('use-task-page-search-actions.ts') - - const passiveSections = [ - sourceBetween(refreshSource, 'const handleRefreshGithubTasks', 'const nextModel'), - sourceBetween(paginationSource, 'const handleLoadNextPage', 'const commitTaskSearch'), - sourceBetween(searchSource, 'const applyPRFilterChange', 'const handleApplyTaskSearch'), - sourceBetween(searchSource, 'const handleApplyTaskSearch', 'const handleTaskSearchChange'), - sourceBetween( - searchSource, - 'const handleTaskSearchChange', - 'const handleSetDefaultTaskPreset' - ), - sourceBetween( - searchSource, - 'const handleSelectGithubTaskKind', - 'const handleResetGithubTaskSearch' - ) - ] - for (const section of passiveSections) { - expect(section).not.toMatch(providerWriter) - } - }) - - it('records GitHub provider-depth for inline item mutation success paths', () => { - const githubWriter = "recordFeatureInteraction('github-tasks')" - // Why: table cells route success telemetry through the optimistic mutation - // hook so provider-depth recording stays on one confirm path. - const hookSource = readFileSync( - join(COMPONENT_ROOT, '../hooks/useTaskPageGitHubWorkItemMutation.ts'), - 'utf8' - ) - expect( - sourceBetween(hookSource, "if (confirmed === 'confirmed')", 'return confirmed') - ).toContain(githubWriter) - expect( - sourceBetween( - componentSource('use-task-page-workspace-actions.ts'), - 'const handleOpenOrUseGitHubWorkItem', - 'const openComposerForGitLabItem' - ) - ).toContain(githubWriter) - }) - - it('threads GitHub task source context through inline task mutations', () => { - const sections = [ - componentSource('task-page/github/StatusCell.tsx'), - componentSource('task-page/github/AssigneesCell.tsx'), - componentSource('task-page/github/ReviewCell.tsx'), - componentSource('task-page/github/MergeCell.tsx'), - sourceBetween( - componentSource('use-task-page-github-issue-creation.ts'), - 'const handleCreateNewIssue', - 'const nextModel' - ) - ] - - for (const section of sections) { - expect(section).toContain('sourceContext') - } - const rowSource = componentSource('task-page/github/Rows.tsx') - // Rows inline the repo lookup per cell rather than hoisting one const. - expect( - rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) - ).toHaveLength(4) - }) - - it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { - const suppression = 'recordTasksInteraction: false' - const githubDetailSection = sourceBetween( - componentSource('use-task-page-github-detail.ts'), - 'const openGitHubDetailPage', - 'const nextModel' - ) - - const inPageNavigationSections = [ - sourceBetween( - componentSource('use-task-page-detail-routing.ts'), - 'const openLinearDetailPage', - 'const openRelatedLinearIssue' - ), - sourceBetween( - componentSource('task-page/SourceBar.tsx'), - 'taskSourceManuallyChangedRef.current = true', - 'void updateSettings' - ) - ] - - expect(githubDetailSection).toContain('openGitHubSourceContext') - expect(githubDetailSection).toContain('openTaskPage') - expect(githubDetailSection).toContain(suppression) - - for (const section of inPageNavigationSections) { - expect(section).toContain(suppression) - } - }) - - it('records Cmd+J create-workspace as its own destination, not a generic quick action', () => { - const source = componentSource('use-worktree-jump-palette-selection-actions.ts') - const section = sourceBetween( - source, - 'const handleSelectQuickAction', - 'const handleSelectProjectTarget' - ) - - expect(section).toContain("recordFeatureInteraction('cmd-j-create-workspace')") - expect(section).toContain("recordFeatureInteraction('cmd-j-quick-action')") - expect(section.indexOf("recordFeatureInteraction('cmd-j-create-workspace')")).toBeLessThan( - section.indexOf("recordFeatureInteraction('cmd-j-quick-action')") - ) - expect( - sourceBetween( - section, - "if (action.id === 'create-workspace')", - "recordFeatureInteraction('cmd-j-quick-action')" - ) - ).toContain('return') - }) - - it('records GitLab provider-depth for detail opens, workspace use, and dialog mutations', () => { - const detailsSource = componentSource('gitlab-item-dialog/use-gitlab-details-editing.ts') - const primarySource = componentSource('gitlab-item-dialog/use-gitlab-primary-actions.ts') - const reviewSource = componentSource('gitlab-item-dialog/use-gitlab-review-actions.ts') - const gitlabWriter = "recordFeatureInteraction('gitlab-tasks')" - - expect( - sourceBetween( - componentSource('task-page/gitlab/ItemList.tsx'), - '{displayedGitLabItems.map((item) => (', - 'handleUseGitLabItem(item)' - ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) - ).toHaveLength(2) - expect( - sourceBetween( - componentSource('use-task-page-workspace-actions.ts'), - 'const handleUseGitLabItem', - 'const nextModel' - ) - ).toContain(gitlabWriter) - - const mutationSections = [ - sourceBetween(detailsSource, 'const handleSaveDetails', 'return {'), - sourceBetween(reviewSource, 'const handleSetReviewers', 'const handleSubmitInlineComment'), - sourceBetween( - reviewSource, - 'const handleSubmitInlineComment', - 'const handleResolveDiscussion' - ), - sourceBetween(primarySource, 'const handleClose', 'const handleReopen'), - sourceBetween(primarySource, 'const handleReopen', 'const handleMerge'), - sourceBetween(primarySource, 'const handleMerge', 'const handleSubmitComment'), - sourceBetween(primarySource, 'const handleSubmitComment', 'return {'), - sourceBetween(reviewSource, 'const handleResolveDiscussion', 'return {') - ] - for (const section of mutationSections) { - expect(section).toContain(gitlabWriter) - expect(section).toContain('showGitLabMutationError') - } - }) - - it('keeps nested GitLab row actions from also opening task details by keyboard', () => { - const rowSection = sourceBetween( - componentSource('task-page/gitlab/ItemList.tsx'), - 'onKeyDown={(e) => {', - 'className="grid w-full cursor-pointer' - ) - expect(rowSection).toContain('e.target !== e.currentTarget') - expect(rowSection.indexOf('e.target !== e.currentTarget')).toBeLessThan( - rowSection.indexOf("e.key === 'Enter'") - ) - }) - - it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('task-page/gitlab/ItemList.tsx')).toContain( - 'key={`${item.repoId}:${item.id}`}' - ) - }) - - it('records Linear provider-depth for inline edits, board drops, creation, and workspace use', () => { - const drawerSource = [ - componentSource('linear-item-drawer-edit-controller.tsx'), - componentSource('linear-item-drawer-comment-footer.tsx') - ].join('\n') - const linearWriter = "recordFeatureInteraction('linear-tasks')" - - const taskPageSections = [ - sourceBetween( - componentSource('task-page-linear-issue-model.tsx'), - 'export function LinearStateCell', - 'return (' - ), - sourceBetween( - componentSource('use-task-page-linear-board.ts'), - 'const handleLinearBoardDrop', - 'const toggleLinearDisplayProperty' - ), - sourceBetween( - componentSource('use-task-page-linear-issue-creation.ts'), - 'const handleCreateNewLinearIssue', - 'const nextModel' - ), - sourceBetween( - componentSource('use-task-page-composer-actions.ts'), - 'const handleUseLinearItem', - 'const handleLinearWorkspaceChange' - ) - ] - for (const section of taskPageSections) { - expect(section).toContain(linearWriter) - } - - const drawerMutationSections = [ - sourceBetween(drawerSource, 'const handleStateChange', 'const handlePriorityChange'), - sourceBetween(drawerSource, 'const handlePriorityChange', 'const handleEstimateChange'), - sourceBetween(drawerSource, 'const handleEstimateChange', 'const handleEstimateSubmit'), - sourceBetween(drawerSource, 'const handleAssigneeChange', 'const handleLabelToggle'), - sourceBetween(drawerSource, 'const handleLabelToggle', 'return {'), - sourceBetween(drawerSource, 'const handleSubmit = useCallback(async () => {', 'return (') - ] - for (const section of drawerMutationSections) { - expect(section).toContain(linearWriter) - } - }) - - it('records Jira provider-depth for workspace use', () => { - const jiraWriter = "recordFeatureInteraction('jira-tasks')" - - // End boundary is the declaration after the handler: the Jira connect flow - // now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior - // marker) no longer exists in TaskPage. - expect( - sourceBetween( - componentSource('use-task-page-composer-actions.ts'), - 'const handleUseJiraItem', - 'const nextModel' - ) - ).toContain(jiraWriter) - }) - - it('records browser annotation agent handoff only from the prompt-delivered callback', () => { - const source = componentSource('browser-pane/annotate/use-browser-page-annotation-send.ts') - expect( - source.match(/recordFeatureInteraction\('browser-annotations-sent-to-agent'\)/g) - ).toHaveLength(1) - expect( - sourceBetween( - source, - 'const handleBrowserAnnotationsSentToAgent', - 'const handleClearBrowserAnnotations' - ) - ).toContain("recordFeatureInteraction('browser-annotations-sent-to-agent')") - expect( - sourceBetween( - source, - 'const handleCopyBrowserAnnotations', - 'const handleBrowserAnnotationsSentToAgent' - ) - ).not.toContain("recordFeatureInteraction('browser-annotations-sent-to-agent')") - expect( - sourceBetween( - source, - 'const handleClearBrowserAnnotations', - 'const handleDeleteBrowserAnnotation' - ) - ).not.toContain("recordFeatureInteraction('browser-annotations-sent-to-agent')") - }) - - it('records floating workspace hide only from explicit disable or hide actions', () => { - const allowedSources = [ - componentSource('settings/FloatingWorkspacePane.tsx'), - componentSource('floating-terminal/FloatingTerminalIconContextMenu.tsx') - ].join('\n') - const passiveSources = [ - componentSource('../App.tsx'), - componentSource('floating-terminal/FloatingTerminalPanel.tsx') - ].join('\n') - - expect( - allowedSources.match(/recordFeatureInteraction\('floating-workspace-hidden'\)/g) ?? [] - ).toHaveLength(2) - expect(passiveSources).not.toContain("recordFeatureInteraction('floating-workspace-hidden')") - }) -}) diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts index 0c5da4935f3..3de131fca5b 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getDefaultOnboardingState, getDefaultVoiceSettings } from '../../../../shared/constants' +import { getDefaultVoiceSettings } from '../../../../shared/constants' +import { getDefaultOnboardingState } from '../../../../shared/onboarding-defaults' import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { OnboardingState } from '../../../../shared/onboarding-state-types' diff --git a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts index a321f57a2ae..4f5aa79a15c 100644 --- a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts +++ b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getDefaultAgentCapabilitySetupSelection } from './agent-capability-setup-status' +import { + getDefaultAgentCapabilitySetupSelection, + isAgentCapabilityReadinessComplete +} from './agent-capability-setup-status' const READY_INPUT = { browserUseSkillInstalled: true, @@ -67,3 +70,31 @@ describe('getDefaultAgentCapabilitySetupSelection', () => { }) }) }) + +describe('isAgentCapabilityReadinessComplete', () => { + it('is complete when every skill is ready, or Computer Use cannot run here', () => { + expect(isAgentCapabilityReadinessComplete(READY_INPUT)).toBe(true) + expect( + isAgentCapabilityReadinessComplete({ + ...READY_INPUT, + computerUseReady: false, + computerUseUnavailable: true + }) + ).toBe(true) + }) + + it('is incomplete while Computer Use still needs macOS access or a skill is missing', () => { + expect(isAgentCapabilityReadinessComplete({ ...READY_INPUT, computerUseReady: false })).toBe( + false + ) + expect( + isAgentCapabilityReadinessComplete({ ...READY_INPUT, orchestrationSkillInstalled: false }) + ).toBe(false) + }) + + it('does not claim completion while install probes are still running', () => { + expect( + isAgentCapabilityReadinessComplete({ ...READY_INPUT, browserUseSkillLoading: true }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx index 7ba461093a8..52a54a208f4 100644 --- a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx +++ b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useAppStore } from '@/store' +import { AgentCapabilityStatusNote, AgentCapabilityStatusPill } from './AgentCapabilityStatusBadges' import { FeatureSetupInlineTerminal } from '../onboarding/FeatureSetupInlineTerminal' import type { OnboardingFeatureSetupRuntimeContext } from '../onboarding/onboarding-feature-setup-runtime' import { @@ -15,13 +16,12 @@ import { type OnboardingFeatureSetupSelection } from '../onboarding/onboarding-feature-setup' import { - getAgentCapabilityStatusClassName, getDefaultAgentCapabilitySetupSelection, isAgentCapabilityReadinessChecking, + isAgentCapabilityReadinessComplete, useAgentCapabilitySetupStatus, type AgentCapabilityInstallStatus } from './agent-capability-setup-status' -import { FullDiskAccessSetupPrompt } from './FullDiskAccessSetupPrompt' import { translate } from '@/i18n/i18n' export function AgentCapabilitiesSetupAction(props: { @@ -140,6 +140,7 @@ export function AgentCapabilitiesSetupAction(props: { featureSetupRuntime={featureSetupRuntime} setupBusyLabel={setupBusyLabel} onStartFeatureSetup={() => void handleStartFeatureSetup()} + allReady={isAgentCapabilityReadinessComplete(readiness)} installStatus={capabilitySetupStatus.installStatus} />
@@ -212,10 +213,13 @@ function AgentCapabilitySetupControls(props: { featureSetupRuntime: OnboardingFeatureSetupRuntimeContext | null setupBusyLabel: string | null onStartFeatureSetup: () => void + allReady: boolean installStatus: Record }): React.JSX.Element { const hasSelectedFeatures = hasSelectedOnboardingFeatureSetup(props.featureSetup) const showSetupAction = !props.featureSetupCommand + // Why: a disabled install button is noise once everything is set up. + const showAllReady = props.allReady && !hasSelectedFeatures && !props.setupBusyLabel return ( <> @@ -224,8 +228,15 @@ function AgentCapabilitySetupControls(props: { onChange={props.onFeatureSetupChange} installStatus={props.installStatus} /> - - {showSetupAction ? ( + {showSetupAction && showAllReady ? ( +

+ + {translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.allInstalled', + 'All skills installed' + )} +

+ ) : showSetupAction ? (
@@ -277,35 +288,32 @@ function AgentCapabilitySetupChecklist(props: { aria-checked={selected} aria-label={`${selected ? 'Disable' : 'Enable'} ${row.title}`} className={cn( - 'flex min-h-24 flex-col rounded-lg border px-4 py-3 text-left transition-colors', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - selected - ? 'border-ring bg-accent text-foreground ring-2 ring-ring/25' - : 'border-border bg-muted/20 text-muted-foreground hover:bg-muted/40' + 'flex min-h-24 flex-col rounded-lg border px-4 py-3.5 text-left transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + selected ? 'border-foreground/60 bg-accent' : 'border-border hover:bg-accent/60' )} onClick={() => props.onChange({ ...props.value, [row.id]: !selected })} > - + {row.icon} - - {selected ? : null} + + + {/* Why: an empty circle on an installed card reads as "not done"; show it only when it means something. */} + {selected || !installStatus.installed ? ( + + {selected ? : null} + + ) : null} {row.title} @@ -320,41 +328,3 @@ function AgentCapabilitySetupChecklist(props: { ) } - -function AgentCapabilityStatusNote(props: { - status: AgentCapabilityInstallStatus -}): React.JSX.Element { - if (props.status.installed) { - return ( - - - {translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.b8dc9dd8a2', - 'Installed' - )} - - {props.status.tone !== 'ready' ? ( - - {props.status.label} - - ) : null} - - ) - } - - return ( - - {props.status.label} - - ) -} diff --git a/src/renderer/src/components/feature-wall/AgentCapabilityStatusBadges.tsx b/src/renderer/src/components/feature-wall/AgentCapabilityStatusBadges.tsx new file mode 100644 index 00000000000..f07c666c69d --- /dev/null +++ b/src/renderer/src/components/feature-wall/AgentCapabilityStatusBadges.tsx @@ -0,0 +1,49 @@ +import { Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + getAgentCapabilityStatusClassName, + type AgentCapabilityInstallStatus +} from './agent-capability-setup-status' + +// Why: pills sit top-right so they line up across cards regardless of description length. +export function AgentCapabilityStatusPill(props: { + status: AgentCapabilityInstallStatus +}): React.JSX.Element | null { + if (props.status.tone === 'unavailable') { + return ( + {props.status.label} + ) + } + if (!props.status.installed) { + return null + } + return ( + + + {translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.b8dc9dd8a2', + 'Installed' + )} + + ) +} + +/** Secondary status text (checking, errors, pending actions); installed/unavailable live in the pill. */ +export function AgentCapabilityStatusNote(props: { + status: AgentCapabilityInstallStatus +}): React.JSX.Element | null { + if (props.status.tone === 'ready' || props.status.tone === 'unavailable') { + return null + } + return ( + + {props.status.label} + + ) +} diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx index 8edf52ec9a8..b31e007e1fb 100644 --- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx +++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx @@ -4,19 +4,11 @@ import { ORCA_CLI_SKILL_NAME, ORCA_CLI_SKILL_UPDATE_COMMAND } from '@/lib/agent-feature-install-commands' -import { - AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal -} from '@/lib/agent-skill-cli-prerequisite' import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from '@/components/settings/AgentSkillSetupPanel' -import { - buildSkillCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getWslCliDistroRequest -} from '@/components/settings/CliSkillRuntimeSetup' +import { buildSkillCommandForRuntime } from '@/components/settings/CliSkillRuntimeSetup' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -34,11 +26,8 @@ export function BrowserUseSkillSetupCard(props: { ? buildSkillCommandForRuntime(ORCA_CLI_SKILL_UPDATE_COMMAND, activeSkillRuntime.agentRuntime) : ORCA_CLI_SKILL_UPDATE_COMMAND - const handleBeforeOpenTerminal = async (): Promise => { + const handleBeforeOpenTerminal = (): void => { useAppStore.getState().recordFeatureInteraction('agent-browser-setup') - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, '1') } @@ -65,14 +54,6 @@ export function BrowserUseSkillSetupCard(props: { error={activeSkillRuntime.installDisabledReason ?? skill.error} installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} terminalHeightPx={terminalHeightPx} - preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={() => - activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus( - getWslCliDistroRequest(activeSkillRuntime.agentRuntime) - ) - : window.api.cli.getInstallStatus() - } onBeforeOpenTerminal={handleBeforeOpenTerminal} showRecheckWhenInstalled={false} onRecheck={skill.refresh} diff --git a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx index 4567f75932e..c84dcc60a52 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx @@ -89,8 +89,8 @@ const BROWSER_ONLY_FEATURE_SETUP: OnboardingFeatureSetupSelection = { linearTickets: false } -// The grab→agent flow relies on the Orca CLI and browser skill, so offer the same -// install action the Enable Orca CLI step uses, scoped to just browser use. +// The grab→agent flow relies on the browser skill, so offer the same install action +// the agent skills step uses, scoped to just browser use. function BrowserSkillInstallButton(): React.JSX.Element { const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [command, setCommand] = useState(null) @@ -186,10 +186,7 @@ function BrowserSkillInstallButton(): React.JSX.Element { 'auto.components.feature.wall.FeatureWallBrowserAction.5f97caf76b', 'Installing…' ) - : translate( - 'auto.components.feature.wall.FeatureWallBrowserAction.c2df599513', - 'Install CLI & Skill' - )} + : translate('auto.components.skills.SkillInstallDialog.39acb9e8f4', 'Install skill')} ) } diff --git a/src/renderer/src/components/feature-wall/FeatureWallRail.tsx b/src/renderer/src/components/feature-wall/FeatureWallRail.tsx index e3224d8ed5d..8c19083acc5 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallRail.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallRail.tsx @@ -109,9 +109,9 @@ export function FeatureWallRail(props: { > void - layout: FeatureWallSetupChecklistLayout }): React.JSX.Element { - const { step, done, active, ordinal, onSelect, layout } = props - const isEmbedded = layout === 'embedded' + const { step, done, active, ordinal, onSelect } = props const localizedStepCopy = getLocalizedFeatureWallSetupChecklistCopy(step) return ( ) @@ -102,20 +99,19 @@ function SetupSection(props: { activeStepId: FeatureWallSetupStepId | null progress: FeatureWallSetupProgress onSelectStep: (id: FeatureWallSetupStepId) => void - layout: FeatureWallSetupChecklistLayout }): React.JSX.Element { const doneCount = props.steps.filter((step) => props.progress.stepDone[step.id]).length return (
-

+

{props.title}

- + {doneCount}/{props.steps.length}
-
+
{props.steps.map((step, index) => ( props.onSelectStep(step.id)} /> ))} @@ -170,6 +165,15 @@ function SelectedStepAction(props: FeatureWallSetupChecklistProps): React.JSX.El return null } +// Full-width content below the caption/visual grid. +function SelectedStepFooter(props: { stepId: FeatureWallSetupStepId }): React.JSX.Element | null { + // Why: Full Disk Access matters for projects in protected folders, so it sits with adding projects. + if (props.stepId === 'add-two-repos') { + return + } + return null +} + function SelectedStepVisual(props: { stepId: FeatureWallSetupStepId }): React.JSX.Element | null { if (props.stepId === 'two-worktrees') { return @@ -299,7 +303,6 @@ export function FeatureWallSetupChecklist( activeStepId={activeStep?.id ?? null} progress={progress} onSelectStep={onSelectStep} - layout={layout} />
@@ -324,31 +326,21 @@ export function FeatureWallSetupChecklist( )} > {activeStep ? ( -
-
-
-
- {getLocalizedFeatureWallSetupChecklistCopy(activeStep).name} -
-
- - {activeDone - ? translate( - 'auto.components.feature.wall.FeatureWallSetupChecklist.13294d3405', - 'Done' - ) - : translate( - 'auto.components.feature.wall.FeatureWallSetupChecklist.0235b268b2', - 'Not done yet' - )} - +
+
+

+ {getLocalizedFeatureWallSetupChecklistCopy(activeStep).name} +

+ {/* Why: a "not done" pill on every open step is noise; only completion is news. */} + {activeDone ? ( + + + {translate( + 'auto.components.feature.wall.FeatureWallSetupChecklist.13294d3405', + 'Done' + )} + + ) : null}

@@ -368,12 +360,13 @@ export function FeatureWallSetupChecklist(

{/* Action lives under the caption, not after the grid, so it sits just below the copy instead of being pushed down by the taller visual. */} -
+
+
) : null}
diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx index 2649029c9b5..268b7dfd567 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.tsx @@ -161,12 +161,26 @@ export function SetupScriptAction(): React.JSX.Element { } }, [repo, setupScript, updateRepo]) + // Why: disabled inputs with the prerequisite in fine print read as broken; lead with the fix. + if (!canConfigure) { + return ( +
+

+ {translate( + 'auto.components.feature.wall.FeatureWallSetupWorkflowActions.486c2f4d8d', + 'Add a git project first, then configure the setup script for that repository.' + )} +

+ +
+ ) + } + return (
setSetupScript(event.target.value)} placeholder={translate( 'auto.components.feature.wall.FeatureWallSetupWorkflowActions.5c5b65044e', @@ -182,7 +196,7 @@ export function SetupScriptAction(): React.JSX.Element { type="button" size="sm" className="gap-2" - disabled={!canConfigure || setupScript.trim().length === 0} + disabled={setupScript.trim().length === 0} onClick={() => void handleSaveSetupScript()} > @@ -197,7 +211,6 @@ export function SetupScriptAction(): React.JSX.Element { variant="ghost" size="sm" className="w-fit gap-2 px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" - disabled={!canConfigure} onClick={openLocalCommandSettings} > @@ -206,14 +219,6 @@ export function SetupScriptAction(): React.JSX.Element { 'View in settings' )} - {!canConfigure ? ( -

- {translate( - 'auto.components.feature.wall.FeatureWallSetupWorkflowActions.486c2f4d8d', - 'Add a git project first, then configure the setup script for that repository.' - )} -

- ) : null}
) } diff --git a/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts b/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts index 6e22269f19c..ff4bf3b75b6 100644 --- a/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts +++ b/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts @@ -80,10 +80,11 @@ describe('FullDiskAccessSetupPrompt state helpers', () => { expect(isFullDiskAccessSetupVisible('unsupported')).toBe(false) }) - it('shows the setup prompt for macOS statuses users can act on', () => { + it('shows the setup prompt only for macOS statuses users can act on', () => { expect(isFullDiskAccessSetupVisible('unknown')).toBe(true) expect(isFullDiskAccessSetupVisible('denied')).toBe(true) - expect(isFullDiskAccessSetupVisible('granted')).toBe(true) + expect(isFullDiskAccessSetupVisible('granted')).toBe(false) + expect(isFullDiskAccessSetupVisible('ready')).toBe(false) }) it('treats granted and entitled statuses as ready', () => { @@ -109,7 +110,7 @@ describe('FullDiskAccessSetupPrompt state helpers', () => { }) expect(getStatus).toHaveBeenCalledTimes(2) - expect(container.textContent).toContain('Granted') + expect(container.textContent).not.toContain('Full Disk Access') root.unmount() }) @@ -130,14 +131,14 @@ describe('FullDiskAccessSetupPrompt state helpers', () => { window.dispatchEvent(new Event('focus')) await Promise.resolve() }) - expect(container.textContent).toContain('Granted') + expect(container.textContent).not.toContain('Full Disk Access') await act(async () => { resolveFirst([{ id: 'full-disk-access', status: 'unknown' }]) await Promise.resolve() }) - expect(container.textContent).toContain('Granted') + expect(container.textContent).not.toContain('Full Disk Access') root.unmount() }) diff --git a/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.tsx b/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.tsx index 8cecfba9082..f11f8f0d063 100644 --- a/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.tsx +++ b/src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { Check, ExternalLink, HardDrive, Loader2 } from 'lucide-react' +import { ExternalLink, HardDrive, Loader2 } from 'lucide-react' import { toast } from 'sonner' import type { DeveloperPermissionId, @@ -18,7 +18,6 @@ type FullDiskAccessStatusState = { } type FullDiskAccessButtonState = { - ready: boolean requesting: boolean } @@ -27,7 +26,8 @@ const FULL_DISK_ACCESS_PERMISSION_ID: DeveloperPermissionId = 'full-disk-access' export function isFullDiskAccessSetupVisible( status: DeveloperPermissionStatus | undefined ): boolean { - return status !== undefined && status !== 'unsupported' + // Why: once granted there is nothing left to do, so the card would only take space. + return status !== undefined && status !== 'unsupported' && !isFullDiskAccessReady(status) } export function isFullDiskAccessReady(status: DeveloperPermissionStatus | undefined): boolean { @@ -47,9 +47,6 @@ function getFullDiskAccessStatusLabel(args: FullDiskAccessStatusState): string { 'Checking' ) } - if (isFullDiskAccessReady(args.status)) { - return translate('auto.components.feature.wall.FullDiskAccessSetupPrompt.48d87edcd2', 'Granted') - } return translate( 'auto.components.feature.wall.FullDiskAccessSetupPrompt.6db9a69f4e', 'Recommended' @@ -63,9 +60,6 @@ function getFullDiskAccessButtonLabel(args: FullDiskAccessButtonState): string { 'Opening...' ) } - if (args.ready) { - return translate('auto.components.feature.wall.FullDiskAccessSetupPrompt.48d87edcd2', 'Granted') - } return translate( 'auto.components.feature.wall.FullDiskAccessSetupPrompt.6e3d62b816', 'Open Full Disk Access' @@ -76,9 +70,6 @@ function FullDiskAccessButtonIcon(props: FullDiskAccessButtonState): React.JSX.E if (props.requesting) { return } - if (props.ready) { - return - } return } @@ -151,8 +142,7 @@ export function FullDiskAccessSetupPrompt(): React.JSX.Element | null { const { checking, refresh, status } = useFullDiskAccessStatus() const mountedRef = useMountedRef() const [requesting, setRequesting] = useState(false) - const ready = isFullDiskAccessReady(status) - const visible = checking || isFullDiskAccessSetupVisible(status) + const visible = isFullDiskAccessSetupVisible(status) const handleOpenFullDiskAccess = useCallback(async (): Promise => { setRequesting(true) @@ -195,7 +185,7 @@ export function FullDiskAccessSetupPrompt(): React.JSX.Element | null { } return ( -
+
@@ -208,9 +198,7 @@ export function FullDiskAccessSetupPrompt(): React.JSX.Element | null { 'Full Disk Access' )} - - {getFullDiskAccessStatusLabel({ checking, status })} - + {getFullDiskAccessStatusLabel({ checking, status })}

{translate( @@ -225,11 +213,11 @@ export function FullDiskAccessSetupPrompt(): React.JSX.Element | null { variant="outline" size="sm" className="shrink-0 gap-1.5" - disabled={ready || requesting || checking} + disabled={requesting || checking} onClick={() => void handleOpenFullDiskAccess()} > - - {getFullDiskAccessButtonLabel({ ready, requesting })} + + {getFullDiskAccessButtonLabel({ requesting })}

) diff --git a/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts b/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts index 9f4fcb31da7..6fac8066a74 100644 --- a/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts +++ b/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts @@ -14,8 +14,14 @@ import { } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { translate } from '@/i18n/i18n' +import { isAgentCapabilitiesDone } from './feature-wall-setup-progress' -export type AgentCapabilityInstallStatusTone = 'ready' | 'pending' | 'checking' | 'error' +export type AgentCapabilityInstallStatusTone = + | 'ready' + | 'pending' + | 'checking' + | 'error' + | 'unavailable' export type AgentCapabilityInstallStatus = { label: string @@ -119,14 +125,18 @@ export function isAgentCapabilityReadinessChecking(readiness: AgentCapabilityRea ) } +export function isAgentCapabilityReadinessComplete(readiness: AgentCapabilityReadiness): boolean { + return !isAgentCapabilityReadinessChecking(readiness) && isAgentCapabilitiesDone(readiness) +} + export function getAgentCapabilityStatusClassName(tone: AgentCapabilityInstallStatusTone): string { switch (tone) { - case 'ready': - return 'text-green-600 dark:text-green-300' case 'error': return 'text-destructive' + case 'ready': case 'checking': case 'pending': + case 'unavailable': return 'text-muted-foreground' } } @@ -166,8 +176,8 @@ function getSkillInstallStatus(skill: { } return { label: translate( - 'auto.components.feature.wall.agent.capability.setup.status.aae94eeb52', - 'Click Install CLI & Skills' + 'auto.components.feature.wall.agent.capability.setup.status.notInstalled', + 'Not installed' ), tone: 'pending' } @@ -211,22 +221,22 @@ function getComputerUseInstallStatus( label: permissions.unavailableReason === 'web_client' ? translate( - 'auto.components.feature.wall.agent.capability.setup.status.4c8e1f92a7', - 'open Orca Desktop on this Mac' + 'auto.components.feature.wall.agent.capability.setup.status.requiresOrcaDesktop', + 'Requires Orca Desktop' ) : translate( 'auto.components.feature.wall.agent.capability.setup.status.6d2b0a84e1', 'Unavailable in this build' ), - tone: 'pending', + tone: 'unavailable', installed: true } } if (!permissions.ready) { return { label: translate( - 'auto.components.feature.wall.agent.capability.setup.status.21d4f79c93', - 'click Install CLI & Skills to open macOS access settings' + 'auto.components.feature.wall.agent.capability.setup.status.needsMacAccess', + 'Needs macOS access' ), tone: 'pending', installed: true diff --git a/src/renderer/src/components/feature-wall/connect-integration-step.tsx b/src/renderer/src/components/feature-wall/connect-integration-step.tsx index fa2c3e71b2e..1ef2b6700e1 100644 --- a/src/renderer/src/components/feature-wall/connect-integration-step.tsx +++ b/src/renderer/src/components/feature-wall/connect-integration-step.tsx @@ -51,9 +51,9 @@ export function IntegrationStep(props: { className={cn( 'flex size-7 shrink-0 items-center justify-center rounded-full border text-[13px] font-semibold leading-none', done - ? 'border-status-success-border bg-status-success-background text-status-success' + ? 'border-foreground bg-foreground text-background' : active - ? 'border-foreground bg-foreground text-background' + ? 'border-foreground text-foreground' : 'border-border text-muted-foreground' )} > diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts index 316011b4a0f..a6d6ed6d880 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts @@ -11,8 +11,8 @@ const getLocalizedFeatureWallSetupChecklistCopyById = createLocalizedCatalog( (): Record => ({ 'two-worktrees': { name: translate( - 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.ec0a363633', - 'Multi-task' + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.workOnTwoTasks', + 'Work on two tasks at once' ), description: translate( 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.62bac8f43c', @@ -51,12 +51,12 @@ const getLocalizedFeatureWallSetupChecklistCopyById = createLocalizedCatalog( }, 'agent-capabilities': { name: translate( - 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.fee5557b02', - 'Enable Orca CLI' + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.agentSkillsName', + 'Give agents Orca skills' ), description: translate( - 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.7bcb4097fa', - 'Register the Orca shell command and install agent skills for browser, computer, and orchestration workflows.' + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.agentSkillsDescription', + "Install the skills agents use to drive Orca's browser, control your computer, and coordinate multi-step work." ) }, 'task-sources': { diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts index 585df18a9ac..e600ddbdaec 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts @@ -42,14 +42,31 @@ function countAvailableNonMainWorktrees(worktreesByRepo: Record = { 'default-agent': Boolean(input.settings?.defaultTuiAgent) && input.settings?.defaultTuiAgent !== 'blank', diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index bc0540cda14..bcd43b3fbb0 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -10,10 +10,6 @@ import { AgentSkillSetupPanel } from '@/components/settings/AgentSkillSetupPanel import { IntegrationStatusPill } from '@/components/integration-status-pill' import { SkillFreshnessStatusPill } from '@/components/skills/SkillFreshnessStatusPill' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' -import { - AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal -} from '@/lib/agent-skill-cli-prerequisite' import { ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCHESTRATION_SKILL_UPDATE_COMMAND @@ -25,11 +21,7 @@ import { import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { refreshSkillFreshness } from '@/hooks/useSkillFreshness' import { useAppStore } from '@/store' -import { - buildSkillCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getWslCliDistroRequest -} from '@/components/settings/CliSkillRuntimeSetup' +import { buildSkillCommandForRuntime } from '@/components/settings/CliSkillRuntimeSetup' import { translate } from '@/i18n/i18n' type FloatingTerminalOrchestrationDialogProps = { @@ -153,20 +145,12 @@ export function FloatingTerminalOrchestrationDialog({ installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} variant="inline" hideHeader - installLabel="Install CLI & skill" - preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={() => - activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus( - getWslCliDistroRequest(activeSkillRuntime.agentRuntime) - ) - : window.api.cli.getInstallStatus() - } - onBeforeOpenTerminal={async () => { + installLabel={translate( + 'auto.components.skills.SkillInstallDialog.39acb9e8f4', + 'Install skill' + )} + onBeforeOpenTerminal={() => { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={recheckOrchestrationSkill} /> diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts index b51cb9a6d78..9fc535e1bbd 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts @@ -38,6 +38,7 @@ export type FloatingPanelStoreState = { closeFile: (fileId: string) => void closeUnifiedTab: (tabId: string) => Tab | null markFileDirty: (fileId: string, dirty: boolean) => void + clearEditorDraft: (fileId: string) => void activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts index 183508fa9aa..d8bb0848c67 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts @@ -31,6 +31,7 @@ export type FloatingTerminalPanelMocks = { > closeBrowserTab: Mock closeWebRuntimeSessionTab: Mock<(args: { worktreeId: string; tabId: string }) => Promise> + clearEditorDraft: Mock closeFile: Mock closeTab: Mock<(tabId: string, options?: { reason?: string }) => void> closeTerminalTab: Mock<(tabId: string, options?: { onClosed?: () => void }) => void> @@ -71,6 +72,7 @@ export const mocks: FloatingTerminalPanelMocks = { activateWebRuntimeSessionTab: vi.fn(), closeBrowserTab: vi.fn(), closeWebRuntimeSessionTab: vi.fn(), + clearEditorDraft: vi.fn(), closeFile: vi.fn(), closeTab: vi.fn(), // Models terminal-tab-actions.closeTerminalTab: a real close removes the tab from the store, then @@ -133,6 +135,7 @@ function resetStore(tabs: TerminalTab[] = []): void { expandedPaneByTabId: {}, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, + clearEditorDraft: mocks.clearEditorDraft, closeFile: mocks.closeFile, closeUnifiedTab: mocks.closeUnifiedTab, createTab: mocks.createTab, diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts index 74d1846e003..8ab51749d38 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts @@ -120,6 +120,7 @@ export function createTerminalSaveDialogModule() { handleSaveDialogDiscard: () => { if (saveDialogBox.fileId) { mocks.markFileDirty(saveDialogBox.fileId, false) + mocks.clearEditorDraft(saveDialogBox.fileId) mocks.closeFile(saveDialogBox.fileId) } saveDialogBox.fileId = null diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts index b6304ecf250..8f82053348f 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts @@ -7,7 +7,7 @@ import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-pa type FloatingTerminalEditorCloseQueueInput = Pick< FloatingTerminalPanelStoreState, - 'floatingFiles' | 'closeFile' | 'markFileDirty' + 'floatingFiles' | 'closeFile' > & Pick< FloatingTerminalPanelLocalState, @@ -17,7 +17,6 @@ type FloatingTerminalEditorCloseQueueInput = Pick< export function useFloatingTerminalEditorCloseQueue({ floatingFiles, closeFile, - markFileDirty, pendingEditorCloseQueueRef, pendingReclaimArmByFileIdRef, saveDialogFileIdRef @@ -29,7 +28,7 @@ export function useFloatingTerminalEditorCloseQueue({ handleSaveDialogSave, handleSaveDialogDiscard, handleSaveDialogCancel - } = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile, markFileDirty }) + } = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile }) const getNextQueuedEditorClose = useCallback((): string | null => { while (pendingEditorCloseQueueRef.current.length > 0) { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts index 354d7831c11..8dda34ef129 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts @@ -11,7 +11,6 @@ export function useFloatingTerminalPanelStoreState() { const closeBrowserTab = useAppStore((state) => state.closeBrowserTab) const closeFile = useAppStore((state) => state.closeFile) const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab) - const markFileDirty = useAppStore((state) => state.markFileDirty) const activateTab = useAppStore((state) => state.activateTab) const setActiveTab = useAppStore((state) => state.setActiveTab) const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle) @@ -43,7 +42,6 @@ export function useFloatingTerminalPanelStoreState() { closeBrowserTab, closeFile, closeUnifiedTab, - markFileDirty, activateTab, setActiveTab, setTabCustomTitle, diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts deleted file mode 100644 index 469991fc55e..00000000000 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -function componentSource(relativePath: string): string { - return readFileSync(join(__dirname, relativePath), 'utf8') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('GitHub Enterprise slug routing boundaries', () => { - it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => { - const statusSection = componentSource('task-page/github/StatusCell.tsx') - const assigneeSection = componentSource('task-page/github/AssigneesCell.tsx') - - expect(statusSection).toContain('host: githubProjectHost(parsedOwnerRepo.host)') - expect(assigneeSection).toContain('parsed?.slug.host') - expect(assigneeSection).toContain('host: githubProjectHost(parsed?.slug.host)') - }) - - it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => { - const reviewSection = componentSource('task-page/github/ReviewCell.tsx') - const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts') - const mergeSection = componentSource('task-page/github/MergeCell.tsx') - - expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)') - expect(reviewSection).toContain('reviewRepo,') - expect(reviewActionsSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) - expect(mergeSection).toContain('const prRepo = resolveTaskPullRequestRepo(item)') - expect(mergeSection).not.toContain('prRepo: item.prRepo ?? null') - }) - - it('keeps PR base-repository hosts on checks-sidebar comment writes', () => { - const source = componentSource( - 'right-sidebar/checks-panel/use-checks-panel-comment-mutations.tsx' - ) - const conversationSection = sourceBetween( - source, - 'const handleEditComment = useCallback', - 'const handleReplyToComment = useCallback' - ) - - expect(conversationSection).toContain('host: githubProjectHost(pr.prRepo.host)') - expect(conversationSection).toContain('updateIssueCommentBySlug({') - expect(conversationSection).toContain('deleteIssueCommentBySlug({') - }) - - it('keeps the primary repository host on task filter metadata reads', () => { - const source = componentSource('github/PRFilterDropdowns.tsx') - - expect(source).toContain('useRepoLabelsBySlug(') - expect(source).toContain('useRepoAssigneesBySlug(') - expect(source.match(/primarySlug\?\.host/g)).toHaveLength(2) - }) -}) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts deleted file mode 100644 index 630ae68a7a0..00000000000 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname - -function componentSource(relativePath: string): string { - // Why: CRLF checkouts would otherwise break every multi-line source assertion. - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8').replace(/\r\n?/g, '\n') -} - -function destSources(...relativePaths: string[]): string { - return relativePaths.map((relativePath) => componentSource(relativePath)).join('\n') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('GitHubItemDialog source host boundaries', () => { - it('does not keep the stale right-side sheet owner', () => { - const source = destSources( - 'GitHubItemDialog.tsx', - 'github-item-dialog/open-dialog/github-item-dialog.tsx' - ) - - expect(source).not.toContain('@/components/ui/sheet') - expect(source).not.toContain(' { - const source = destSources( - 'github-item-dialog/land-pull-request/pr-reviewers-panel.tsx', - 'github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts' - ) - const section = source - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('sourceSettings') - expect(section).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(section).toContain( - 'const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId)' - ) - expect(section).toContain("'github.requestPRReviewers'") - expect(section).toContain("'github.removePRReviewers'") - expect(section).toContain('resolvePullRequestRepo(item, projectOrigin)') - expect(section.match(/prRepo: reviewRepo/g)).toHaveLength(4) - expect(section).toContain('notifyWorkItemDetailsMutation(') - expect(section).toContain('{ local: false }') - }) - - it('routes edit metadata through the same task source as issue mutations', () => { - const source = destSources( - 'github-item-dialog/edit-item-fields/gh-edit-section.tsx', - 'github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts' - ) - const section = source - const helperSection = componentSource('github/github-work-item-edit-mutations.ts') - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoLabels(') - expect(section).toContain('useRepoLabelsBySlug(') - expect(section).toContain('projectOrigin?.host') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(helperSection).toContain("'github.updateIssue'") - expect(helperSection).toContain("'github.updatePRState'") - expect(helperSection).toContain("'github.project.updateIssueBySlug'") - expect(helperSection).toContain("'github.project.updatePullRequestBySlug'") - expect(helperSection).toContain("args.sourceContext?.provider === 'github'") - expect(helperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)') - expect(helperSection).toContain( - 'getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext)' - ) - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain( - "repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')" - ) - expect(helperSection).toContain('{ local: false }') - }) - - it('uses source-aware details routing and cache identity', () => { - const source = destSources( - 'github-item-dialog/load-item-details/work-item-details-cache.ts', - 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts' - ) - const cacheKeySection = sourceBetween( - source, - 'function getWorkItemDetailsCacheKey', - 'function touchWorkItemDetailsCache' - ) - const matchInvalidationSection = sourceBetween( - source, - 'function invalidateWorkItemDetailsCacheByMatch', - 'function patchCachedPRFileViewedState' - ) - - expect(source).toContain('lookupGitHubWorkItemDetailsForSource({') - expect(source).toContain('sourceContext,') - expect(cacheKeySection).toContain('sourceCacheScope') - expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' - ) - }) - - it('treats null details as unavailable while preserving empty detail payloads', () => { - const source = destSources( - 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts', - 'github-item-dialog/load-item-details/work-item-details-fetch-settle.ts' - ) - const loadedSection = sourceBetween( - source, - 'const loading = !!cachedEntry?.pending && !cachedEntry?.details', - '// Why: if a cross-window mutation invalidates' - ) - const resultSection = sourceBetween(source, 'inflight', '.catch((err) => {') - - expect(loadedSection).toContain('const detailsLoaded = Boolean(cachedEntry?.details)') - expect(loadedSection).not.toContain('fetchedAt > 0') - expect(resultSection).toContain('} else if (result === null) {') - expect(resultSection).toContain('error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE') - expect(resultSection).toContain('details: result') - }) - - it('routes PR file viewed mutations through the task source context', () => { - const changeSection = destSources( - 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts', - 'github-item-dialog/load-item-details/pr-file-viewed-change.ts' - ) - const helperSection = componentSource('github/github-work-item-comment-mutations.ts') - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.setPRFileViewed'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('{ local: false }') - expect(changeSection).toContain('canUseDetailsRepoContext') - expect(changeSection).toContain('repoPath: repoPath ??') - expect(changeSection).toContain('sourceContext,') - }) - - it('routes comment mutations through runtime source context when needed', () => { - const helperSource = componentSource('github/github-work-item-comment-mutations.ts') - const helperSection = sourceBetween( - helperSource, - 'function addIssueCommentForRepo', - 'function setPRFileViewedForRepo' - ) - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.addIssueComment'") - expect(helperSection).toContain("'github.addPRReviewComment'") - expect(helperSection).toContain("'github.addPRReviewCommentReply'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain('{ local: false }') - }) - - it('routes PR file contents and runtime viewed invalidations through the task source context', () => { - const source = destSources( - 'github-item-dialog/load-item-details/work-item-details-cache.ts', - 'github-item-dialog/load-item-details/pr-file-content-cache.ts' - ) - const commentMutations = componentSource('github/github-work-item-comment-mutations.ts') - const fileContentsSection = sourceBetween( - source, - 'function loadPRFileContents', - 'touchPRFileContentCache(cacheKey, request)' - ) - const fileContentsCacheKeySection = sourceBetween( - source, - 'function getPRFileContentCacheKey', - 'function loadPRFileContents' - ) - const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') - - expect(fileContentsCacheKeySection).toContain( - 'source:${getTaskSourceCacheScope(args.sourceContext)}' - ) - expect(fileContentsSection).toContain("'github.prFileContents'") - expect(fileContentsSection).toContain( - 'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)' - ) - expect(fileContentsSection).toContain('sourceContext: args.sourceContext') - expect(fileContentsSection).toContain('sourceContext,') - expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(commentMutations).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') - expect(commentMutations).toContain('options.local !== false') - expect(commentMutations).toContain('notifyWorkItemMutated({') - }) - - it('routes merge actions through the repo owner host (#6957)', () => { - const actionsSection = componentSource( - 'github-item-dialog/land-pull-request/pr-actions-panel.tsx' - ) - - expect(actionsSection).toContain( - 'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)' - ) - expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(actionsSection).toContain( - 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' - ) - expect(actionsSection).toContain("'github.mergePR'") - expect(actionsSection).toContain("'github.setPRAutoMerge'") - expect(actionsSection).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)') - expect(actionsSection).not.toContain('prRepo: item.prRepo ?? null') - expect(actionsSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' - ) - expect(actionsSection).toContain('sourceContext,') - expect(actionsSection).toContain('notifyWorkItemDetailsMutation(') - expect(actionsSection).toContain('{ local: false }') - }) - - it('routes check actions through the task source context', () => { - const checksSection = destSources( - 'github-item-dialog/inspect-pull-request/checks-tab.tsx', - 'github-item-dialog/inspect-pull-request/checks-tab-actions.ts', - 'github-item-dialog/inspect-pull-request/checks-tab-request-details.ts' - ) - - expect(checksSection).toContain('sourceContext?: TaskSourceContext | null') - expect(checksSection).toContain('sourceContext,') - expect(checksSection).toContain("'github.prChecks'") - expect(checksSection).toContain("'github.rerunPRChecks'") - expect(checksSection).toContain("'github.prCheckDetails'") - expect(checksSection).toContain( - 'repo: getGitHubRuntimeRepoId(ctx.sourceContext, ctx.repoId ?? ctx.itemRepoId)' - ) - expect(checksSection).toContain('window.api.gh.prChecks({') - expect(checksSection).toContain('window.api.gh.rerunPRChecks({') - expect(checksSection).toContain('prCheckDetails({') - expect(checksSection).toMatch( - /withGitHubCheckDetailsTimeout\(\(signal\) =>\s*ctx\.runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/ - ) - expect(checksSection).toContain('{ timeoutMs: 30_000, signal }') - }) - - it('makes failed check detail loads retryable and fences stale responses', () => { - const checksSection = destSources( - 'github-item-dialog/inspect-pull-request/checks-tab.tsx', - 'github-item-dialog/inspect-pull-request/checks-tab-actions.ts', - 'github-item-dialog/inspect-pull-request/checks-tab-request-details.ts', - 'github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx' - ) - - expect(checksSection).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)') - expect(checksSection).toContain('checksState,\n checks,\n checkDetailsContextKey') - expect(checksSection).toContain('resetGitHubChecksTabForSource(current)') - expect(checksSection).toContain( - 'committedChecksContextOwnerRef.current !== refreshContextOwner' - ) - expect(checksSection).toContain('activeChecksRefreshRequestIdRef.current !== refreshRequestId') - expect(checksSection).toContain('current.contextOwner === refreshContextOwner') - expect(checksSection).toContain( - 'const rerunContextOwner = ctx.committedChecksContextOwnerRef.current' - ) - expect(checksSection).toContain( - 'ctx.committedChecksContextOwnerRef.current !== rerunContextOwner' - ) - expect(checksSection).toContain('await refreshGitHubChecksTab(ctx, rerunContextOwner)') - expect(checksSection).toContain('!ctx.mountedRef.current ||') - expect(checksSection).toContain('settleGitHubChecksTabDetails(current, key, requestId, next)') - expect(checksSection).toContain('onClick={() => onRetry(check, getCheckDetailsKey(check))}') - expect(checksSection).toContain('disabled={state.loading}') - expect(checksSection).toContain('aria-busy={state.loading}') - expect(checksSection).toContain("translate('githubChecks.retrying', 'Retrying…')") - expect(checksSection).toContain("'Retry'") - }) - - it('uses hydrated work item details for the page checks tab', () => { - const source = componentSource('github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx') - const checksTab = sourceBetween( - source, - ' { - const editSection = destSources( - 'github-item-dialog/edit-item-fields/gh-edit-section.tsx', - 'github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts' - ) - expect(editSection).toContain('assertTaskPageGitHubDialogStateAuthority({') - expect(editSection).toContain('if (authority?.revert())') - - const actionsSection = componentSource( - 'github-item-dialog/land-pull-request/pr-actions-panel.tsx' - ) - expect(actionsSection.match(/assertTaskPageGitHubDialogStateAuthority\(\{/g)).toHaveLength(2) - expect(actionsSection).toContain('if (authority.revert())') - expect(actionsSection).toContain("state: 'merged'") - }) -}) diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx index d6da1177779..b29f8d97fce 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx @@ -9,6 +9,7 @@ import type { DiffSection } from '@/components/editor/diff-section-types' import { getCombinedDiffBranchEntriesInTreeOrder } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-filter' import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' import { useAppStore } from '@/store' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types' import { isPRFileViewed } from '@/components/github/pr-file-content-size' import { @@ -92,9 +93,7 @@ function PRFilesCombinedDiffSections({ setFileTreeCollapsed }: PRFilesCombinedDiffSectionsProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const isDark = useDocumentDarkTheme() // Why: this subtree is keyed by the diff signature, so its file set is fixed for the // mount. Freezing it in state keeps a stable identity without caching through a ref. const [entries] = useState(() => diff --git a/src/renderer/src/components/github-pr-reviewer-display.test.ts b/src/renderer/src/components/github-pr-reviewer-display.test.ts index 3512b578e29..b0bb049208b 100644 --- a/src/renderer/src/components/github-pr-reviewer-display.test.ts +++ b/src/renderer/src/components/github-pr-reviewer-display.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { GITHUB_PR_REVIEWER_INPUT_MAX_BYTES, - appendGitHubPRRequestedReviewers, getGitHubPRPrimaryReviewer, getGitHubPRReviewerRows, getGitHubPRReviewLabel, @@ -110,18 +109,6 @@ describe('GitHub PR reviewer display', () => { ]) }) - it('appends requested reviewers without duplicating existing logins', () => { - expect( - appendGitHubPRRequestedReviewers( - [{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }], - ['examplereviewer', '@new-reviewer'] - ) - ).toEqual([ - { login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }, - { login: 'new-reviewer', name: null, avatarUrl: '' } - ]) - }) - it('normalizes reviewer input before sending it to GitHub', () => { expect( normalizeGitHubReviewerLogins( diff --git a/src/renderer/src/components/github-pr-reviewer-display.ts b/src/renderer/src/components/github-pr-reviewer-display.ts index 01ba370e0a9..02ff345f4f2 100644 --- a/src/renderer/src/components/github-pr-reviewer-display.ts +++ b/src/renderer/src/components/github-pr-reviewer-display.ts @@ -188,27 +188,3 @@ export function getGitHubPRReviewerRows(item: ReviewDisplayItem): GitHubPRReview } return Array.from(byLogin.values()) } - -export function appendGitHubPRRequestedReviewers( - current: readonly GitHubAssignableUser[], - logins: readonly string[] -): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of current) { - const login = user.login.trim() - if (login) { - byLogin.set(login.toLowerCase(), user) - } - } - for (const rawLogin of logins) { - const login = rawLogin.trim().replace(/^@/, '') - if (!login) { - continue - } - const key = login.toLowerCase() - if (!byLogin.has(key)) { - byLogin.set(key, { login, name: null, avatarUrl: '' }) - } - } - return Array.from(byLogin.values()) -} diff --git a/src/renderer/src/components/github/PRFilterPickers.test.ts b/src/renderer/src/components/github/PRFilterPickers.test.ts index 6bff74ab0f1..1adfdce4274 100644 --- a/src/renderer/src/components/github/PRFilterPickers.test.ts +++ b/src/renderer/src/components/github/PRFilterPickers.test.ts @@ -3,7 +3,6 @@ import { PULL_REQUEST_PICKER_QUERY_MAX_BYTES, filterPullRequestPickerOptions, getPullRequestPickerQueryState, - isPullRequestPickerQueryTooLarge, type PickerOption } from './PRFilterPickers' @@ -38,7 +37,6 @@ describe('filterPullRequestPickerOptions', () => { } ] - expect(isPullRequestPickerQueryTooLarge(oversizedQuery)).toBe(true) expect(filterPullRequestPickerOptions(throwingOptions, oversizedQuery)).toEqual([]) }) diff --git a/src/renderer/src/components/github/PRFilterPickers.tsx b/src/renderer/src/components/github/PRFilterPickers.tsx index 43264ccc3a6..0c5a2ab4389 100644 --- a/src/renderer/src/components/github/PRFilterPickers.tsx +++ b/src/renderer/src/components/github/PRFilterPickers.tsx @@ -18,7 +18,7 @@ export type PickerOption = { key: string; primary: string; secondary?: string } export const PULL_REQUEST_PICKER_QUERY_MAX_BYTES = 2 * 1024 -export function isPullRequestPickerQueryTooLarge( +function isPullRequestPickerQueryTooLarge( query: string, maxBytes = PULL_REQUEST_PICKER_QUERY_MAX_BYTES ): boolean { diff --git a/src/renderer/src/components/github/github-mention-option-filter.test.ts b/src/renderer/src/components/github/github-mention-option-filter.test.ts index 9e9022faff1..429cf51c3b2 100644 --- a/src/renderer/src/components/github/github-mention-option-filter.test.ts +++ b/src/renderer/src/components/github/github-mention-option-filter.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { GITHUB_MENTION_QUERY_MAX_BYTES, filterGitHubMentionOptions, - isGitHubMentionQueryTooLarge, type GitHubMentionOption } from './github-mention-option-filter' @@ -31,7 +30,6 @@ describe('github-mention-option-filter', () => { const query = 'é'.repeat(GITHUB_MENTION_QUERY_MAX_BYTES) expect(query.length).toBe(GITHUB_MENTION_QUERY_MAX_BYTES) - expect(isGitHubMentionQueryTooLarge(query)).toBe(true) expect(filterGitHubMentionOptions([option('octocat')], query)).toEqual([]) }) @@ -46,7 +44,6 @@ describe('github-mention-option-filter', () => { } } as GitHubMentionOption - expect(isGitHubMentionQueryTooLarge(oversizedQuery)).toBe(true) expect(filterGitHubMentionOptions([candidate], oversizedQuery)).toEqual([]) }) }) diff --git a/src/renderer/src/components/github/github-mention-option-filter.ts b/src/renderer/src/components/github/github-mention-option-filter.ts index 7a986a4f887..17a5ad7f9d6 100644 --- a/src/renderer/src/components/github/github-mention-option-filter.ts +++ b/src/renderer/src/components/github/github-mention-option-filter.ts @@ -8,7 +8,7 @@ export type GitHubMentionOption = { export const GITHUB_MENTION_QUERY_MAX_BYTES = 2 * 1024 export const GITHUB_MENTION_OPTION_LIMIT = 8 -export function isGitHubMentionQueryTooLarge( +function isGitHubMentionQueryTooLarge( query: string, maxBytes = GITHUB_MENTION_QUERY_MAX_BYTES ): boolean { diff --git a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts index c8ef5e977e3..713934e7d3a 100644 --- a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts +++ b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts @@ -3,8 +3,7 @@ import type { GitHubAssignableUser } from '../../../../shared/github/pull-reques import { GITHUB_PR_REVIEWER_QUERY_MAX_BYTES, filterGitHubPRReviewerCandidates, - getGitHubPRReviewerQueryState, - isGitHubPRReviewerQueryTooLarge + getGitHubPRReviewerQueryState } from './github-pr-reviewer-candidate-filter' function user(login: string, name: string | null = null): GitHubAssignableUser { @@ -38,7 +37,6 @@ describe('github-pr-reviewer-candidate-filter', () => { const query = 'é'.repeat(GITHUB_PR_REVIEWER_QUERY_MAX_BYTES) expect(query.length).toBe(GITHUB_PR_REVIEWER_QUERY_MAX_BYTES) - expect(isGitHubPRReviewerQueryTooLarge(query)).toBe(true) expect(getGitHubPRReviewerQueryState(query)).toEqual({ query: '', isTooLarge: true }) expect( filterGitHubPRReviewerCandidates({ @@ -60,7 +58,6 @@ describe('github-pr-reviewer-candidate-filter', () => { avatarUrl: '' } as GitHubAssignableUser - expect(isGitHubPRReviewerQueryTooLarge(oversizedQuery)).toBe(true) expect( filterGitHubPRReviewerCandidates({ candidates: [candidate], diff --git a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts index f38bd80e9a0..4f6037da7b0 100644 --- a/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts +++ b/src/renderer/src/components/github/github-pr-reviewer-candidate-filter.ts @@ -8,7 +8,7 @@ export type GitHubPRReviewerQueryState = { export const GITHUB_PR_REVIEWER_QUERY_MAX_BYTES = 2 * 1024 -export function isGitHubPRReviewerQueryTooLarge( +function isGitHubPRReviewerQueryTooLarge( query: string, maxBytes = GITHUB_PR_REVIEWER_QUERY_MAX_BYTES ): boolean { diff --git a/src/renderer/src/components/github/github-user-avatar.tsx b/src/renderer/src/components/github/github-user-avatar.tsx index 9bf71b0c852..3c19ffb21c0 100644 --- a/src/renderer/src/components/github/github-user-avatar.tsx +++ b/src/renderer/src/components/github/github-user-avatar.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { cn } from '@/lib/utils' -export function githubAvatarUrl(login: string): string { +function githubAvatarUrl(login: string): string { return `https://github.com/${encodeURIComponent(login)}.png?size=64` } diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts deleted file mode 100644 index 8b33b3dc40c..00000000000 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Issue #8784 — GHE PR avatars must prefer API avatar_url over github.com/{login}.png. - * - * Regression guard (was a repro that documented the broken path). After the fix: - * - resolveGitHubUserAvatarSrc prefers API URLs - * - PullRequestPage author uses authorAvatarUrl via GitHubUserAvatar - * - TaskPage ReviewChipAvatar no longer hardcodes github.com login.png - * - * Re-run: - * pnpm exec vitest run --config config/vitest.config.ts \ - * src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts - */ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { githubAvatarUrl, resolveGitHubUserAvatarSrc } from './github-user-avatar' - -describe('issue #8784 GHE avatar fallback (regression)', () => { - it('prefers API avatar_url over login.png (GHE healthy path)', () => { - const api = 'https://ghe.example.com/avatars/u/42?v=4' - expect(resolveGitHubUserAvatarSrc('enterprise-only-user', api)).toBe(api) - }) - - it('trims whitespace-only avatarUrl and falls back to login.png for github.com', () => { - expect(resolveGitHubUserAvatarSrc('octocat', ' ')).toBe( - 'https://github.com/octocat.png?size=64' - ) - expect(resolveGitHubUserAvatarSrc('octocat', null)).toBe(githubAvatarUrl('octocat')) - }) - - it('returns null when neither avatarUrl nor login is usable (no bogus request)', () => { - expect(resolveGitHubUserAvatarSrc('', '')).toBeNull() - expect(resolveGitHubUserAvatarSrc(' ', null)).toBeNull() - }) - - it('login-only fallback still hardcodes public github.com png (github.com path)', () => { - // Why: github.com users without avatar_url still use this; GHE relies on - // enrichment + image onError → initials when this 404s. - expect(githubAvatarUrl('corp-user')).toBe('https://github.com/corp-user.png?size=64') - }) - - it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => { - const prPage = readFileSync(join(__dirname, '../pull-request-page/page/header.tsx'), 'utf8') - expect(prPage).toMatch(/GitHubUserAvatar/) - expect(prPage).toMatch(/authorAvatarUrl/) - // Why: author chip must not ignore API avatar_url and only pass login. - expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) - - const reviewChip = readFileSync(join(__dirname, '../task-page/github/Avatars.tsx'), 'utf8') - expect(reviewChip).toMatch(/GitHubUserAvatar/) - // Why: list chip must not hardcode github.com/{login}.png. - expect(reviewChip).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) - }) - - // GHES URLs can exist but fail unauthenticated; target slots need onError fallbacks. - // Scope checks because TaskPage also renders non-GitHub provider avatars. - const GITHUB_AVATAR_SLOTS = [ - { - file: 'pull-request-page/reviewers/picker-row.tsx', - fn: 'ReviewerPickerRow', - login: 'reviewer.login', - displayName: 'reviewer.name' - }, - { - file: 'pull-request-page/conversation/comment-card.tsx', - fn: 'ConversationCommentCard', - login: 'comment.author', - displayName: null - }, - { - file: 'pull-request-page/mentions/textarea.tsx', - fn: 'MentionTextarea', - login: 'option.login', - displayName: 'option.name' - }, - { - file: 'task-page/github/Avatars.tsx', - fn: 'GitHubAssigneeAvatar', - login: 'assignee.login', - displayName: 'assignee.name' - }, - { - file: 'task-page/github/AssigneesCell.tsx', - fn: 'GHAssigneesCell', - login: 'user.login', - displayName: 'user.name' - }, - { - file: 'task-page/github/ReviewerPicker.tsx', - fn: 'TaskPageGitHubReviewerPicker', - login: 'reviewer.login', - displayName: 'reviewer.name' - } - ] as const - - function componentBody(file: string, fn: string): string { - const source = readFileSync(join(__dirname, '..', file), 'utf8') - const start = source.indexOf(`function ${fn}`) - expect(start, `${file}: function ${fn} not found`).toBeGreaterThanOrEqual(0) - const next = source.indexOf('\nfunction ', start + 1) - return source.slice(start, next === -1 ? undefined : next) - } - - it.each(GITHUB_AVATAR_SLOTS)( - 'renders the $fn avatar through GitHubUserAvatar (#13976)', - ({ file, fn, login }) => { - const body = componentBody(file, fn) - - // Reject aliases and resolver expressions as well as direct avatarUrl fields. - expect(body, `${fn} still renders a bare img`).not.toMatch(/]/) - expect(body, `${fn} does not use GitHubUserAvatar`).toContain(' slot.displayName !== null))( - 'passes the $fn display name so initials are not reduced to one letter (#13976)', - ({ file, fn, displayName }) => { - expect(componentBody(file, fn)).toContain(`name={${displayName}}`) - } - ) -}) diff --git a/src/renderer/src/components/jira-project-picker-filter.test.ts b/src/renderer/src/components/jira-project-picker-filter.test.ts index 2c41742c5fc..6013ee691ce 100644 --- a/src/renderer/src/components/jira-project-picker-filter.test.ts +++ b/src/renderer/src/components/jira-project-picker-filter.test.ts @@ -3,8 +3,7 @@ import type { JiraProject } from '../../../shared/jira-types' import { JIRA_PROJECT_PICKER_QUERY_MAX_BYTES, filterJiraProjectPickerProjects, - getJiraProjectPickerDisplayLabel, - isJiraProjectPickerQueryTooLarge + getJiraProjectPickerDisplayLabel } from './jira-project-picker-filter' function project(overrides: Partial = {}): JiraProject { @@ -54,7 +53,6 @@ describe('jira-project-picker-filter', () => { const query = 'é'.repeat(JIRA_PROJECT_PICKER_QUERY_MAX_BYTES) expect(query.length).toBe(JIRA_PROJECT_PICKER_QUERY_MAX_BYTES) - expect(isJiraProjectPickerQueryTooLarge(query)).toBe(true) expect( filterJiraProjectPickerProjects({ projects: [project()], @@ -78,7 +76,6 @@ describe('jira-project-picker-filter', () => { } } as JiraProject - expect(isJiraProjectPickerQueryTooLarge(oversizedQuery)).toBe(true) expect( filterJiraProjectPickerProjects({ projects: [candidate], diff --git a/src/renderer/src/components/jira-project-picker-filter.ts b/src/renderer/src/components/jira-project-picker-filter.ts index 0e8d060bf50..998800afa6c 100644 --- a/src/renderer/src/components/jira-project-picker-filter.ts +++ b/src/renderer/src/components/jira-project-picker-filter.ts @@ -3,7 +3,7 @@ import type { JiraProject } from '../../../shared/jira-types' export const JIRA_PROJECT_PICKER_QUERY_MAX_BYTES = 2 * 1024 -export function isJiraProjectPickerQueryTooLarge( +function isJiraProjectPickerQueryTooLarge( query: string, maxBytes = JIRA_PROJECT_PICKER_QUERY_MAX_BYTES ): boolean { diff --git a/src/renderer/src/components/linear-issue-workspace-api-parity.test.ts b/src/renderer/src/components/linear-issue-workspace-api-parity.test.ts deleted file mode 100644 index 91310627cf7..00000000000 --- a/src/renderer/src/components/linear-issue-workspace-api-parity.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -function componentSource(file: string): string { - return readFileSync(join(__dirname, file), 'utf8') -} - -describe('Linear issue workspace provider API parity', () => { - it('keeps detail and comments on the selected source and workspace', () => { - const source = componentSource('linear-issue-workspace-detail-state.ts') - expect(source).toContain('initialRequest.providerSettings,') - expect(source).toContain('initialRequest.issue.id,') - expect(source).toContain('initialRequest.issue.workspaceId') - expect(source).toContain('targetProviderSettings,') - expect(source).toContain('targetIssue.id,') - expect(source).toContain('targetIssue.workspaceId') - }) - - it('keeps project search and assignment source-scoped', () => { - const source = componentSource('linear-issue-project-selector.tsx') - expect(source).toContain( - 'linearListProjects(providerSettings, requestQuery, 20, issue.workspaceId)' - ) - expect(source).toMatch( - /linearUpdateIssue\(\s*providerSettings,\s*issue\.id,\s*\{ projectId: project\.id \},\s*issue\.workspaceId/ - ) - expect(source).toContain('patchLinearIssue(issue.id, { project }, { sourceContext })') - }) - - it('keeps sub-issue create and open payloads source-scoped', () => { - const source = componentSource('linear-issue-sub-issues.tsx') - expect(source).toContain('parentIssueId: issue.id') - expect(source).toContain('teamId: issue.team.id') - expect(source).toContain('workspaceId: issue.workspaceId') - expect(source).toContain('projectId: issue.project?.id ?? null') - expect(source).toMatch( - /fetchLinearIssue\(subIssue\.id, issue\.workspaceId, \{\s*sourceContext\s*\}\)/ - ) - }) - - it('keeps git and folder workspace attachments in the same lookup', () => { - const source = componentSource('LinearIssueWorkspace.tsx') - expect(source).toContain('...allWorktrees') - expect(source).toContain('...folderWorkspaces.map(folderWorkspaceToWorktree)') - expect(source).toContain('findLinearIssueWorkspaceAttachment(attachmentWorkspaces') - expect(source).toContain('openLinearIssueWorkspaceOrStart(detail.displayed') - }) -}) diff --git a/src/renderer/src/components/linear-issue-workspace-header.test.ts b/src/renderer/src/components/linear-issue-workspace-header.test.ts deleted file mode 100644 index 4fd36b23bf4..00000000000 --- a/src/renderer/src/components/linear-issue-workspace-header.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(join(__dirname, 'linear-issue-workspace-header.tsx'), 'utf8') - -function headerSource(): string { - const start = source.indexOf('
') - expect(header).toContain('') - expect(header).toContain("'Start workspace'") - expect(header).toContain("'Start workspace from issue'") - expect(header).not.toContain("'Copy issue identifier'") - expect(header).toMatch(/size="sm"[\s\S]*onClick=\{onOpenOrUseIssue\}/) - expect(header).not.toMatch(/size="icon-sm"\s+onClick=\{onOpenOrUseIssue\}/) - }) -}) diff --git a/src/renderer/src/components/linear-scope-selector.test.ts b/src/renderer/src/components/linear-scope-selector.test.ts index 3e8d69f69c7..90b06f4e636 100644 --- a/src/renderer/src/components/linear-scope-selector.test.ts +++ b/src/renderer/src/components/linear-scope-selector.test.ts @@ -4,7 +4,6 @@ import { LINEAR_SCOPE_TEAM_FILTER_QUERY_MAX_BYTES, filterLinearScopeTeams, getLinearScopeTriggerLabel, - isLinearScopeTeamFilterQueryTooLarge, normalizeLinearScopeTeamSelection } from './linear-scope-selector' @@ -125,7 +124,6 @@ describe('LinearScopeSelector helpers', () => { } ] as LinearTeam[] - expect(isLinearScopeTeamFilterQueryTooLarge(oversizedQuery)).toBe(true) expect(filterLinearScopeTeams(throwingTeams, oversizedQuery, new Map())).toEqual([]) }) diff --git a/src/renderer/src/components/linear-scope-selector.tsx b/src/renderer/src/components/linear-scope-selector.tsx index e6a1712fab1..331c667bc58 100644 --- a/src/renderer/src/components/linear-scope-selector.tsx +++ b/src/renderer/src/components/linear-scope-selector.tsx @@ -38,7 +38,7 @@ type LinearScopeTeamSelectionInput = { export const LINEAR_SCOPE_TEAM_FILTER_QUERY_MAX_BYTES = 2 * 1024 -export function isLinearScopeTeamFilterQueryTooLarge( +function isLinearScopeTeamFilterQueryTooLarge( query: string, maxBytes = LINEAR_SCOPE_TEAM_FILTER_QUERY_MAX_BYTES ): boolean { diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 78c5fa207b6..452aa78cc81 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -17,7 +17,10 @@ import { type ResumeCandidate, type ResumeFailure } from './native-chat-resume-on-restart-grouping' -import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { + consumeNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' /** * Which interrupted chats the host is still offering to resume, and every action that moves that. @@ -50,20 +53,43 @@ let launch: Promise | undefined * a re-read that raced it must neither pre-empt nor undo. */ let actionsBegun = 0 let actionsSettled = 0 +/** The chats each in-flight continue call names, so the status bar can report the resume after + * the dialog that started it has closed. Held only for the call's lifetime; never persisted. */ +const resumeBatches = new Set() +const NOTHING_RESUMING: readonly string[] = [] +let resuming: readonly string[] = NOTHING_RESUMING const listeners = new Set<() => void>() const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const +function emit(): void { + for (const listener of listeners) { + listener() + } +} + /** The snapshot object is replaced HERE and nowhere else — never during a render — so every * `useSyncExternalStore` reader sees the same reference until a host answer or a user action * actually moves the offer. */ function publish(next: NativeChatRestartOffer): void { offer = next syncOfferedChatWatch() - for (const listener of listeners) { - listener() + emit() +} + +/** A confirmed host answer. One with nothing left also retires any open request for the dialog, + * which has nothing to show; a failed read only hides rows, so it keeps the request. */ +function publishAnswer(next: NativeChatRestartOffer): void { + publish(next) + if (next.candidates.length === 0 && next.failed.length === 0) { + consumeNativeChatResumeOnRestartDialogRequest() } } +function syncResuming(): void { + resuming = resumeBatches.size === 0 ? NOTHING_RESUMING : [...new Set([...resumeBatches].flat())] + emit() +} + /** * Re-reads the host once an offered or failed chat shows new activity, so a message the user sent * there, or its agent starting, retires its entry here too. The host stays the judge; this only @@ -167,6 +193,10 @@ export function getNativeChatRestartOffer(): NativeChatRestartOffer { return offer } +export function getNativeChatRestartResuming(): readonly string[] { + return resuming +} + function subscribe(listener: () => void): () => void { listeners.add(listener) return () => { @@ -206,7 +236,7 @@ async function readNativeChatRestartOffer(current = () => true): Promise { actionsBegun += 1 + const batch = [...reported] + resumeBatches.add(batch) + syncResuming() try { const result = await callStructuredAgentSession< HostOfferPayload & { @@ -269,7 +302,7 @@ export async function continueNativeChatRestartOffer( failureToastActions ) if (Array.isArray(result.sessions)) { - publish({ candidates: result.sessions, failed, listedAt: Date.now() }) + publishAnswer({ candidates: result.sessions, failed, listedAt: Date.now() }) } else { await refreshNativeChatRestartOffer() } @@ -278,6 +311,8 @@ export async function continueNativeChatRestartOffer( announceRestartUnconfirmed(reported.length) } finally { actionsSettled += 1 + resumeBatches.delete(batch) + syncResuming() } } @@ -298,7 +333,11 @@ export async function dismissNativeChatRestartOffer(sessionIds?: readonly string sessionIds ? { sessionIds: [...sessionIds] } : {} ) if (Array.isArray(result.sessions)) { - publish({ candidates: result.sessions, failed: failedFrom(result), listedAt: Date.now() }) + publishAnswer({ + candidates: result.sessions, + failed: failedFrom(result), + listedAt: Date.now() + }) } else { await refreshNativeChatRestartOffer() } @@ -361,10 +400,17 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer) } +/** The chats a resume is carrying on right now, whichever surface started it. */ +export function useNativeChatRestartResuming(): readonly string[] { + return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming) +} + /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { releaseOfferedChatWatch() offer = EMPTY + resumeBatches.clear() + resuming = NOTHING_RESUMING actionsBegun = 0 actionsSettled = 0 launch = undefined diff --git a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx index 8e621a8a441..9549fc933e7 100644 --- a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx @@ -72,9 +72,15 @@ export function NativeChatAwaitingInputRow({ const questions = subject?.kind === 'questions' && listsQuestions ? subject.questions : null // A count always hides its questions, so the list needs no clipping measurement. const toggles = questions !== null || (question !== null && (open || clipped)) - const label = pending - ? translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting) - : translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked) + // With nothing to name, the label must read as a whole phrase, not end on a colon. + const label = !pending + ? translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked) + : subject === null + ? translate( + 'components.native-chat.ask.awaitingUnnamed', + NATIVE_CHAT_ASK_ROW_COPY.awaitingUnnamed + ) + : translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting) const text = subject === null ? null diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 8eb4c9b7469..ad5ad98a62a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -100,7 +100,7 @@ vi.mock('@/lib/native-chat-telemetry', () => ({ vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { mocks.draftScopeKeys.push(scopeKey) - return { draft: mocks.draft, setDraft: mocks.setDraft } + return { draft: mocks.draft, setDraft: mocks.setDraft, flushDraftAppends: () => {} } } })) vi.mock('./native-chat-draft-cache', () => ({ @@ -475,7 +475,7 @@ describe('NativeChatComposer', () => { expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() }) - it.each(['claude', 'openclaude'] as const)('keeps %s slash composer sends pasted', (agent) => { + it.each(['claude'] as const)('keeps %s slash composer sends pasted', (agent) => { mocks.draft = '/clear' render( (null) const [dictationPressed, setDictationPressed] = useState(false) - const imeEnterGesture = useImeEnterGestureOwnership() const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, @@ -339,6 +343,8 @@ const NativeChatComposerPane = forwardRef 0, + ...(steerQueued ? { steerQueued } : {}), setActiveSuggestion, setDraft, setCaret, @@ -386,6 +392,7 @@ const NativeChatComposerPane = forwardRef ({ })) import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' +import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card' const mocks = { sendAnswer: vi.fn(), @@ -45,6 +46,8 @@ function renderCard(canSend = true): ReturnType { return render(cardElement(canSend)) } +const NO_MESSAGES: readonly NativeChatMessage[] = [] + function cardElement( canSend = true, messages?: readonly NativeChatMessage[], @@ -52,11 +55,36 @@ function cardElement( transcriptSettled = true ): React.JSX.Element { return ( - + ) +} + +// The view derives the card and hands it over; this stands in for that view. +function CardHarness({ + canSend, + messages, + onShowingQuestionChange, + transcriptSettled +}: { + canSend: boolean + messages?: readonly NativeChatMessage[] + onShowingQuestionChange?: (showing: boolean) => void + transcriptSettled: boolean +}): React.JSX.Element | null { + const card = useNativeChatInteractivePromptCard({ + paneKey: 'tab-1:leaf-1', + messages: messages ?? NO_MESSAGES, + transcriptSettled: transcriptSettled && messages !== undefined + }) + return ( + void @@ -51,26 +36,7 @@ export function NativeChatInteractiveCard({ * a target while the composer is unmounted. */ answerInputRef?: React.RefObject }): React.JSX.Element | null { - const interactivePrompt = useAppStore( - (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null - ) - // Thread the sibling `toolName` from the same status entry so the question - // parser can dispatch through the tool's registered parser (mobile parity). - const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null) const { sendAnswer, sendRaw, cancelPending, cancel } = send - - const card = useMemo(() => { - const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined) - if (statusCard?.kind === 'approval') { - return statusCard - } - const prompt = resolveNativeChatAsk({ - liveAsk: statusCard?.prompt ?? null, - messages: messages ?? [], - transcriptSettled: transcriptSettled && messages != null - }) - return prompt ? { kind: 'question' as const, prompt } : null - }, [interactivePrompt, interactiveToolName, messages, transcriptSettled]) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) // A question answer is a paced multi-step write (body→Enter per question); keep diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx index b357b951b2c..819ee94dbd5 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx @@ -255,6 +255,46 @@ describe('older history auto-load', () => { expect(loadEarlier).toHaveBeenCalledTimes(3) }) + // A page of a subagent's rows lands in its closed section and adds no row the + // reader can see; paging must continue the same way. + it("keeps paging after a page that holds only a settled subagent's rows", async () => { + const loadEarlier = lands() + const spawn: NativeChatMessage = { + id: 'spawn', + role: 'system', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: 'task-1', label: 'explore the lane', state: 'completed' }] + } + ], + timestamp: 100.5, + source: 'transcript' + } + const base = [spawn, ...markers(100, 150)] + const { container, rerender } = render(paging({ messages: base, loadEarlier })) + sentinelInRange = true + deliverIntersections() + expect(loadEarlier).toHaveBeenCalledTimes(1) + + rerender(paging({ messages: base, loadEarlier, loadingEarlier: true })) + await settle() + const subagentPage = [ + ...Array.from({ length: 20 }, (_, index) => ({ + ...marker(index), + id: `child-${index}`, + agentId: 'task-1' + })), + ...base + ] + rerender(paging({ messages: subagentPage, loadEarlier })) + paint(container) + deliverIntersections() + expect(screen.queryByText('marker-0')).toBeNull() + expect(loadEarlier).toHaveBeenCalledTimes(2) + }) + it('stops once a page pushes the sentinel out of range', async () => { const loadEarlier = lands() const base = markers(100, 150) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx index 248b2bae40b..28032f519cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { ProviderFrameRow } from './NativeChatMessageList' +import { ProviderFrameRow } from './NativeChatTranscriptChrome' afterEach(cleanup) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx new file mode 100644 index 00000000000..1762674739d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const text = ( + id: string, + role: NativeChatMessage['role'], + body: string, + timestamp: number +): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: body }], + timestamp, + source: 'transcript' +}) +const toolRun = (id: string, timestamp: number): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'sleep 5' }, state: 'completed' }], + timestamp, + source: 'transcript' +}) + +// Claude runs A; B is sent mid-turn and Claude answers it after A. The journal writes B when it is +// sent, so A's remaining tool run and its answer follow B there, and B's turn opens after them. +const messages = [ + text('user-a', 'user', 'Run the first job', 1), + toolRun('a-tool-1', 2), + text('user-b', 'user', 'Then run the second job', 3), + toolRun('a-tool-2', 4), + text('a-answer', 'assistant', 'FIRST DONE', 5), + text('b-answer', 'assistant', 'SECOND DONE', 6) +] + +/** With `statesScope` false the rows carry no turn scope, as a host older than turn scopes writes. */ +function journal(bState: 'running' | 'completed', statesScope: boolean): AgentJournalRenderItem[] { + const thread: AgentJournalTurnScope = { kind: 'thread' } + const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + const row = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope, + sequence: number + ): AgentJournalRenderItem => ({ + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(statesScope ? { turnScope } : {}) + }) + const said = (role: 'user' | 'assistant'): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [] + }) + return [ + row('user-a', said('user'), thread, 1), + row( + 'turn-a', + { kind: 'turn', turnId: 'turn-a', state: 'completed', userItemId: 'user-a' }, + thread, + 2 + ), + row('a-tool-1', said('assistant'), inTurn('turn-a'), 3), + // Handed over while A runs, so the host scopes it to A's turn until its own opens. + row('user-b', said('user'), inTurn('turn-a'), 4), + row('a-tool-2', said('assistant'), inTurn('turn-a'), 5), + row('a-answer', said('assistant'), inTurn('turn-a'), 6), + row( + 'turn-b', + { kind: 'turn', turnId: 'turn-b', state: bState, userItemId: 'user-b' }, + thread, + 7 + ), + row('b-answer', said('assistant'), inTurn('turn-b'), 8) + ] +} + +const session = (status: NativeChatLiveSession['status']): NativeChatLiveSession => ({ + messages, + status, + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' +}) + +function expectInOrder(...nodes: Element[]): void { + for (const [index, node] of nodes.slice(1).entries()) { + expect(nodes[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } +} + +const bar = (label: string | RegExp): Element => + screen.getByText(label).closest('[data-native-chat-turn-status]')! + +describe.each([['states each row’s turn', true]])( + 'a message the provider answered after the running turn, on a host that %s', + (_host, statesScope) => { + it("draws A's remaining rows and answer under A's bar, then B's bubble and turn", () => { + render( + + ) + + expectInOrder( + screen.getByText('Run the first job'), + bar('Worked for 17s'), + screen.getByText('FIRST DONE'), + screen.getByText('Then run the second job'), + bar(/Working for/), + screen.getByText('SECOND DONE') + ) + }) + + it('keeps that order once both turns settle', () => { + render( + + ) + + expectInOrder( + screen.getByText('Run the first job'), + bar('Worked for 17s'), + screen.getByText('FIRST DONE'), + screen.getByText('Then run the second job'), + bar('Worked for 51s'), + screen.getByText('SECOND DONE') + ) + }) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx new file mode 100644 index 00000000000..968dc5b92e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx @@ -0,0 +1,369 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatSubagentState } from '../../../../shared/native-chat-types' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import { + foldStructuredAgentSubagentRoster, + NO_STRUCTURED_AGENT_SUBAGENT_ROSTER +} from '../../../../shared/structured-agent-session-subagent-roster' +import { NativeChatMessageList } from './NativeChatMessageList' +import { session, stubLayout } from './native-chat-windowing-test-harness' + +afterEach(cleanup) + +function journalItem( + itemId: string, + body: AgentJournalItemBody, + sequence: number, + linkage: AgentJournalProducerLinkage = {} +): AgentJournalRenderItem { + return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1, ...linkage } +} + +const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } +const patch = '@@ -1 +1 @@\n-before\n+after' +const itemsWith = (state: NativeChatSubagentState, answered: boolean): AgentJournalRenderItem[] => [ + journalItem( + 'ask', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Review it' }] }, + 1 + ), + journalItem( + 'spawn', + { + kind: 'message', + role: 'system', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: 'task-1', label: 'explore the lane', state }] + } + ] + }, + 2 + ), + journalItem( + 'child-said', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'The PR is CLEAN.' }] }, + 3, + child + ), + journalItem( + 'child-edit', + { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + 4, + child + ), + ...(answered + ? [ + journalItem( + 'answer', + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Delegated; nothing to fix.' }] + }, + 5 + ) + ] + : []) +] + +/** `waiting`: the session runs and has produced nothing since the roster. */ +function listOf(state: NativeChatSubagentState, waiting = false): React.JSX.Element { + const items = itemsWith(state, !waiting) + return ( + + ) +} + +const renderList = () => render(listOf('completed')) + +describe("a subagent's rows in the transcript", () => { + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + vi.restoreAllMocks() + }) + + it("shows the conversation and none of the subagent's words until its entry opens them", () => { + renderList() + expect(screen.getByText('Delegated; nothing to fix.')).toBeInTheDocument() + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + + // Named once: its entry heads its rows, which come before the parent's answer. + expect(screen.getAllByText('explore the lane')).toHaveLength(1) + const entry = screen.getByRole('button', { name: /explore the lane/, expanded: true }) + const said = screen.getByText('The PR is CLEAN.') + expect(entry.compareDocumentPosition(said)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(said.compareDocumentPosition(screen.getByText('Delegated; nothing to fix.'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + + fireEvent.click(entry) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + expect(screen.getByRole('button', { name: /explore the lane/, expanded: false })).toBeVisible() + }) + + // happy-dom has no layout, so the geometry is pinned by the margin that decides it: + // outside a section a row's controls hang into the gap below it (`-mb-5`), and + // inside one that overhang would leave them below the section's border, touching + // the parent's next row. + it("keeps a section row's controls inside the row that carries the section's border", () => { + renderList() + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + const controlsOf = (text: string): HTMLElement => { + const row = screen.getByText(text).closest('.group') + expect(row).not.toBeNull() + return within(row!).getByRole('button', { name: 'Scroll this message to top' }).parentElement! + } + + const sectionControls = controlsOf('The PR is CLEAN.') + expect(sectionControls.closest('.border-l-2')).not.toBeNull() + expect(sectionControls).not.toHaveClass('-mb-5') + expect(controlsOf('Delegated; nothing to fix.')).toHaveClass('-mb-5') + }) + + it("draws an open agent's rows between its entry and the next one", () => { + const agents = [ + { id: 'task-a', label: 'lane a', state: 'completed' as const }, + { id: 'task-b', label: 'lane b', state: 'completed' as const }, + { id: 'task-c', label: 'lane c', state: 'completed' as const } + ] + const items = [ + itemsWith('completed', false)[0]!, + journalItem( + 'spawn', + { + kind: 'message', + role: 'system', + blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }] + }, + 2 + ), + ...agents.map(({ id, label }, index) => + journalItem( + `${id}-said`, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `Read ${label}.` }] + }, + 3 + index, + { agentId: id, producerKind: 'agent' } + ) + ) + ] + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Ran 3 subagents/ })) + fireEvent.click(screen.getByRole('button', { name: /lane b/ })) + + const order = [ + screen.getByRole('button', { name: /lane a/, expanded: false }), + screen.getByRole('button', { name: /lane b/, expanded: true }), + screen.getByText('Read lane b.'), + screen.getByRole('button', { name: /lane c/, expanded: false }) + ] + for (const [index, node] of order.slice(1).entries()) { + expect(order[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } + expect(screen.queryByText('Read lane a.')).toBeNull() + expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3) + }) + + it("still counts the subagent's edit in the turn, and reveals it inside its section", () => { + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) + renderList() + expect(screen.queryByText('Edited file')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + + expect(screen.getByText('Edited file')).toBeInTheDocument() + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() + }) + + // Rows are grouped by the journal's turn records, so a subagent's edit counts in the + // turn that was running when it was made: not a send queued mid-turn, whose own turn + // had not opened, and in a turn keyed to its own record rather than the last prompt. + it("counts a subagent's edit in the turn the journal says it was made in", () => { + const say = (itemId: string, role: 'user' | 'assistant', text: string, sequence: number) => + journalItem(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, sequence) + const turn = (turnId: string, userItemId: string, sequence: number) => + journalItem( + `turn-${turnId}`, + { kind: 'turn', turnId, state: 'completed', userItemId, startedAt: sequence * 1000 }, + sequence + ) + const childEdit = (itemId: string, path: string, sequence: number) => + journalItem( + itemId, + { + kind: 'diff', + path, + patch: { head: patch, truncated: false, digest: path, byteLength: patch.length } + }, + sequence, + child + ) + const [ask, spawn] = itemsWith('completed', false) + const items = [ + ask!, + turn('1', 'ask', 2), + { ...spawn!, sequence: 3 }, + say('ask-2', 'user', 'And the tests?', 4), + childEdit('child-edit', 'src/a.ts', 5), + say('answer-1', 'assistant', 'Edited a.', 6), + turn('2', 'ask-2', 7), + say('answer-2', 'assistant', 'Tests pass.', 8), + // Its opener is outside the loaded window, so the turn keys to its own record. + turn('3', 'older-send', 9), + childEdit('child-woke', 'src/b.ts', 10), + say('answer-3', 'assistant', 'Picked up the result.', 11) + ] + render( + + ) + const rollups = screen.getAllByRole('button', { name: /changed file/ }) + expect(rollups.map((rollup) => rollup.textContent)).toEqual([ + expect.stringMatching(/1 changed file/), + expect.stringMatching(/1 changed file/) + ]) + const inOrder = [ + screen.getByText('Edited a.'), + rollups[0]!, + screen.getByText('Tests pass.'), + screen.getByText('Picked up the result.'), + rollups[1]! + ] + for (const [index, node] of inOrder.slice(1).entries()) { + expect(inOrder[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } + }) + + it('reveals an edit under a roster list the reader closed', () => { + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) + renderList() + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + + expect(screen.getByText('Edited file')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() + }) + + it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => { + const items = itemsWith('completed', true) + const view = (withRoster: boolean) => { + const shown = withRoster ? items : items.filter((next) => next.itemId !== 'spawn') + return ( + + ) + } + const { rerender } = render(view(true)) + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + + rerender(view(false)) + rerender(view(true)) + expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + }) + + it('opens a working subagent while the session waits on it, and closes it once the parent moves on', () => { + const { rerender } = render(listOf('working', true)) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() + expect(screen.getAllByText('explore the lane')).toHaveLength(1) + rerender(listOf('working')) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + // The closed roster still says the agent works. + expect(screen.getByRole('button', { name: /Kicked off 1 subagent/ })).toBeInTheDocument() + }) + + it("keeps the reader's choice over the frontier, in either direction", () => { + const { rerender } = render(listOf('working', true)) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: true })) + rerender(listOf('working', true)) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + + // Closing the section from its entry left the list open. + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + rerender(listOf('working')) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + }) + + it('names a section from the client roster when its roster row is not loaded', () => { + const items = itemsWith('completed', true) + const loaded = items.filter((next) => next.itemId !== 'spawn') + render( + + ) + expect(screen.getByRole('button', { name: /explore the lane/, expanded: false })).toBeVisible() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx index 4be1ea7cdb3..82b2c43d468 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx @@ -70,7 +70,6 @@ function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { isWorking={false} expandSignal fontScale={1} - showTurnStatus={false} /> ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx index 2a2401bea52..a77d2907b6a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -44,15 +44,7 @@ function Transcript({ items }: { items: AgentJournalRenderItem[] }) { loadEarlier, readPhase: 'ready' } - return ( - - ) + return } function row(index: number, body: AgentJournalRenderItem['body']): AgentJournalRenderItem { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index ee697732671..efe1c54dd01 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -3,16 +3,13 @@ import { ArrowDown } from 'lucide-react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { translate } from '@/i18n/i18n' import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' +import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection' import { structuredQuestionTranscript } from './structured-agent-question-projection' import { nativeChatTaskListState } from './native-chat-task-list-state' import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' import { NativeChatTaskList } from './NativeChatTaskList' -import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' -import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' -import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' import { useNativeChatTurnStatus } from './use-native-chat-turn-status' -import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow' +import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' @@ -20,14 +17,22 @@ import { NativeChatDisclosureContext, useNativeChatDisclosures } from './native-chat-disclosure-store' -import { NativeChatTranscriptItems } from './NativeChatTranscriptItems' +import { + NativeChatTranscriptItems, + NativeChatWaitingTranscriptItems +} from './NativeChatTranscriptItems' import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { buildNativeChatTranscriptSlots, + splitNativeChatSlotsWaitingBehindLiveTurn, nativeChatSlotIndexOf } from './native-chat-transcript-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' +import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' +import { useNativeChatSubagentSections } from './use-native-chat-subagent-sections' +import { toggleNativeChatExpandedKey } from './native-chat-expanded-keys' +import { useNativeChatTurnMembership } from './use-native-chat-turn-membership' import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' import { useNativeChatOlderHistoryAutoload } from './use-native-chat-older-history-autoload' import { NativeChatOlderHistoryRow } from './NativeChatOlderHistoryRow' @@ -40,14 +45,12 @@ import type { import { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump' import { nativeChatReaderScrollInputHandlers } from './native-chat-reader-scroll-input' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' -import { nativeChatSubagentLabels } from '../../../../shared/native-chat-subagent-attribution' -import { - selectNativeChatActiveTurnKey, - type NativeChatSettledTurns -} from '../../../../shared/native-chat-turn-status' -import { nativeChatRowTurnKeys } from '../../../../shared/native-chat-turn-grouping' +import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, type NativeChatDiffReveal, @@ -55,9 +58,9 @@ import { type NativeChatTurnDiff } from './native-chat-turn-diffs' -export { ProviderFrameRow } from './NativeChatTranscriptChrome' - -const MAX_EXPANDED_TURNS = 128 +/** The turn is blocked on the reader. `shown`: the pane draws the prompt itself, as a card; + * `unshown`: it cannot (the prompt is only in the agent's terminal). */ +export type NativeChatAwaitingInput = 'shown' | 'unshown' type NativeChatNavigationRequest = | { kind: 'diff'; target: NativeChatDiffReveal } @@ -66,6 +69,8 @@ type NativeChatNavigationRequest = export function NativeChatMessageList({ session, journalItems, + journalSubmissions, + subagentRoster, railOutline = null, isVisible = true, isWorking, @@ -75,16 +80,17 @@ export function NativeChatMessageList({ allowFileUriLinks = false, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId = null, deliveryNotices, - showTurnStatus = true, - showLiveTurnActivity = true, + awaitingInput = null, turnActivity, runtimeContext }: { session: NativeChatLiveSession journalItems?: readonly AgentJournalRenderItem[] + /** With the items, what places each row in its turn (structured lane). */ + journalSubmissions?: readonly AgentJournalSubmission[] + /** Every subagent the session's rosters named, whether or not its roster row is loaded. */ + subagentRoster?: Parameters[2] /** User messages older than the loaded window, from the host's outline. */ railOutline?: readonly NativeChatRailOutlineEntry[] | null isVisible?: boolean @@ -94,20 +100,14 @@ export function NativeChatMessageList({ /** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */ fontScale: number workingStartedAt?: number | null - /** Host-recorded turn durations keyed by user message id (structured lane). */ + /** Recorded turn durations keyed by user message id (the host's, or the transcript's). + * A turn missing here shows the duration this list observed, if it saw the turn run. */ settledTurns?: NativeChatSettledTurns - /** The key the host says anchors the running turn's bar (structured lane). */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership per journal item id (structured lane). - * Rows it does not name keep positional preceding-user grouping. */ - turnKeysByItemId?: ReadonlyMap | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap - /** Turn timing and disclosure are available on structured agent sessions. */ - showTurnStatus?: boolean - /** Whether the active turn's foreground activity row should be visible. */ - showLiveTurnActivity?: boolean + /** Set while the turn waits on the reader; the live activity line yields to it. */ + awaitingInput?: NativeChatAwaitingInput | null turnActivity?: NativeChatTurnActivity | null runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { @@ -126,21 +126,7 @@ export function NativeChatMessageList({ const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()) const disclosures = useNativeChatDisclosures() const toggleExpandedTurn = useCallback((turnKey: string) => { - setExpandedTurnIds((current) => { - const next = new Set(current) - if (next.has(turnKey)) { - next.delete(turnKey) - } else { - if (next.size >= MAX_EXPANDED_TURNS) { - const oldest = next.values().next().value - if (oldest) { - next.delete(oldest) - } - } - next.add(turnKey) - } - return next - }) + setExpandedTurnIds((current) => toggleNativeChatExpandedKey(current, turnKey)) }, []) const { hasMore, loadingEarlier, loadEarlier } = session @@ -148,81 +134,90 @@ export function NativeChatMessageList({ // remounts the row, which re-checks the range. const showOlderHistory = hasMore && session.readPhase === 'ready' - const projectMessages = useMemo( - () => createNativeChatMessageListProjection(), - // Rebound sessions must release the previous transcript's cached rows. - // eslint-disable-next-line react-hooks/exhaustive-deps - [session.agent, session.sessionId] + const { messages, subagentRows } = useNativeChatTranscriptProjection( + session, + journalItems, + journalSubmissions ) - const messages = useMemo(() => { - const projected = projectNativeChatTaskListFrames(projectMessages(session.messages)) - // Structured sessions show goal state in the banner above the composer. - return journalItems ? omitNativeChatThreadGoalRows(projected) : projected - }, [journalItems, projectMessages, session.messages]) - const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages]) + const { + sections: subagentSections, + subagentRowsInOrder, + subagentChoices, + subagentDisclosure, + openSubagentSections + } = useNativeChatSubagentSections(messages, subagentRows, subagentRoster) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) - const showTypingIndicator = showTurnStatus - ? isWorking - : shouldShowNativeChatTypingIndicator({ messages, isWorking }) - // Resolve each row's owning turn once. Prefix slice/findLast in the render - // loop becomes quadratic for long transcripts. - const turnKeys = useMemo( - () => nativeChatRowTurnKeys(messages, showTurnStatus ? turnKeysByItemId : null), - [messages, showTurnStatus, turnKeysByItemId] - ) - const turnDiffs = useMemo( - () => - journalItems - ? nativeChatTurnDiffs(messages, turnKeys) - : new Map(), - [journalItems, messages, turnKeys] - ) + // Each row's turn, which turn is live, and the order the rows draw in, resolved once. + const { + messages: rows, + turnKeys, + liveTurnKey + } = useNativeChatTurnMembership(messages, journalItems, journalSubmissions) + const turnDiffs = useMemo(() => { + if (!journalItems) { + return new Map() + } + const merged = nativeChatRowsInTranscriptOrder(rows, turnKeys, subagentRowsInOrder) + return nativeChatTurnDiffs(merged.messages, merged.turnKeys, subagentSections.pathOf) + }, [journalItems, rows, subagentRowsInOrder, subagentSections.pathOf, turnKeys]) // "Thinking" is real reasoning content at the tail of the turn, not the absence // of output — the latter reports thinking while the request is merely in flight. const thinking = useMemo( () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), [journalItems] ) - const activeTurnKey = selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) const turnStatuses = useNativeChatTurnStatus({ - messages, - activeTurnKey, - isWorking: showTurnStatus && isWorking, - workingStartedAt: showTurnStatus ? workingStartedAt : null, - settledTurns: showTurnStatus ? settledTurns : null, + turnKeys, + liveTurnKey, + isWorking, + workingStartedAt, + settledTurns, thinking }) + // The transcript tail: what the running turn is doing, or that it waits on a + // prompt nothing else on screen shows. A prompt card says so itself. + const tailRow = + awaitingInput === 'unshown' + ? 'awaiting-input' + : isWorking && awaitingInput === null + ? 'activity' + : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' - const slots = useMemo( + const allSlots = useMemo( () => buildNativeChatTranscriptSlots({ - messages, + messages: rows, turnKeys, - activeTurnKey, + liveTurnKey, receipts, turnStatuses, turnDiffs, - showTurnStatus, expandedTurnKeys: expandedTurnIds, isWorking, lifecycleWorking, - subagentLabels + subagentSections, + subagentChoices }), [ - activeTurnKey, + liveTurnKey, expandedTurnIds, isWorking, lifecycleWorking, - messages, receipts, - showTurnStatus, - subagentLabels, + rows, + subagentChoices, + subagentSections, turnDiffs, turnKeys, turnStatuses ] ) + // A message waiting behind the live turn draws after that turn's live activity, not inside it. + const { slots, waitingSlots } = useMemo( + () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), + [allSlots, journalItems] + ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, slots, @@ -236,7 +231,7 @@ export function NativeChatMessageList({ contentRef, itemCount: slots.length, isWorking, - showTypingIndicator, + showsTailRow: tailRow !== null, isVisible, alignToViewportTop: transcriptWindow.alignToViewportTop, scrollToEnd: transcriptWindow.scrollToEnd, @@ -290,13 +285,17 @@ export function NativeChatMessageList({ const revealDiff = useCallback( (target: NativeChatDiffTarget) => { beginNavigation() + // A subagent's edit is revealed inside its section. + if (target.subagentSections) { + openSubagentSections(target.subagentSections) + } navigationSequence.current += 1 setNavigationRequest({ kind: 'diff', target: { ...target, requestId: navigationSequence.current } }) }, - [beginNavigation] + [beginNavigation, openSubagentSections] ) const jumpToLatest = useCallback(() => { beginNavigation() @@ -331,7 +330,6 @@ export function NativeChatMessageList({ const rowContext = useMemo( () => ({ expandSignal, - showTurnStatus, revealedDiff, taskListPredecessors, expandedTurnIds, @@ -340,6 +338,7 @@ export function NativeChatMessageList({ runtimeContext, onLinkClick, onToggleExpandedTurn: toggleExpandedTurn, + subagentDisclosure, onScrollMessageToTop: scrollMessageToTop, onRevealDiff: revealDiff }), @@ -353,8 +352,8 @@ export function NativeChatMessageList({ revealedDiff, runtimeContext, scrollMessageToTop, - showTurnStatus, taskListPredecessors, + subagentDisclosure, toggleExpandedTurn ] ) @@ -398,13 +397,15 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {showTurnStatus && showLiveTurnActivity && isWorking ? ( + {tailRow === 'activity' ? ( + ) : tailRow === 'awaiting-input' ? ( + ) : null} - {!showTurnStatus && showTypingIndicator ? : null} +
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx index 635eef94c35..7c62a9efe3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx @@ -4,6 +4,11 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' @@ -72,6 +77,40 @@ function session(startedAt: number): NativeChatLiveSession { } } +/** The journal behind `messages`: one turn `user-1` opened, holding every later row. With + * `statesScope` false, the rows carry no turn scope, as an older host writes them. */ +function journalOf( + messages: readonly NativeChatMessage[], + statesScope: boolean +): AgentJournalRenderItem[] { + const scope = (turnScope: AgentJournalTurnScope) => (statesScope ? { turnScope } : {}) + const [opener, ...rest] = messages + const entry = (message: NativeChatMessage, sequence: number): AgentJournalRenderItem => ({ + itemId: message.id, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: message.role === 'user' ? 'user' : 'assistant', + blocks: [{ type: 'text', text: message.id }] + }, + ...scope({ kind: 'turn', turnItemId: 'turn-1' }) + }) + return [ + { ...entry(opener!, 1), ...scope({ kind: 'thread' }) }, + { + itemId: 'turn-1', + revision: 0, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: opener!.id }, + ...scope({ kind: 'thread' }) + }, + ...rest.map((message, index) => entry(message, index + 3)) + ] +} + // A finished turn is the transcript's resting state, and it should read as the // answer to the prompt — not as the transcript of the work that produced it. describe('NativeChatMessageList settled turn fold', () => { @@ -143,40 +182,46 @@ describe('NativeChatMessageList settled turn fold', () => { // The #23621 shape: the host folded a mid-turn send into the running turn, so // the rows after it are still the opener's. One bar under the opener folds // them all; the steered bubble stays visible and never grows a bar or group. - it("folds the rows after a mid-turn send behind the opener's bar", () => { - const startedAt = Date.now() - 3000 - const base = session(startedAt) - const steer: (typeof base.messages)[number] = { - id: 'mid-b', - role: 'user', - blocks: [{ type: 'text', text: 'Also check the tests.' }], - timestamp: startedAt + 2, - source: 'transcript' + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + "folds the rows after a mid-turn send behind the opener's bar on a host that %s", + (_host, statesScope) => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + const steer: (typeof base.messages)[number] = { + id: 'mid-b', + role: 'user', + blocks: [{ type: 'text', text: 'Also check the tests.' }], + timestamp: startedAt + 2, + source: 'transcript' + } + const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] + render( + + ) + + expect(screen.getByText('Also check the tests.')).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) + expect(toggles).toHaveLength(1) + + fireEvent.click(toggles[0]!) + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() } - const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] - const turnKeysByItemId = new Map(messages.map((message) => [message.id, 'user-1'])) - render( - - ) - - expect(screen.getByText('Also check the tests.')).toBeInTheDocument() - expect(screen.queryByText(NARRATION)).toBeNull() - expect(screen.queryByText(MORE_NARRATION)).toBeNull() - const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) - expect(toggles).toHaveLength(1) - - fireEvent.click(toggles[0]!) - expect(screen.getByText(NARRATION)).toBeInTheDocument() - expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() - }) + ) // The answer is the LAST prose the agent produced. A turn whose final output // is a tool run still answers with the prose before it. diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index 8400b592e7d..d48c1d04dab 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -9,7 +9,8 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import type { AgentJournalItemBody, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../../shared/agent-session-journal-types' // The turn record this host writes, and the legacy status row an older host sends. @@ -166,7 +167,7 @@ describe('NativeChatMessageList turn indicator', () => { }} journalItems={[journalItem(1, turnItem), journalItem(2, reasoningRow)]} isWorking - showLiveTurnActivity={false} + awaitingInput="shown" expandSignal={false} fontScale={1} /> @@ -178,46 +179,36 @@ describe('NativeChatMessageList turn indicator', () => { expect(screen.getByText('Running 1 command')).toHaveClass('animate-pulse') }) - it('keeps the live row up after a tool settles', () => { - render( + // A terminal-backed pane can learn its agent is waiting without being handed + // the prompt; the tail then says so, in the row a question uses. + it('says the turn waits on the reader when no card shows the prompt', () => { + const { container } = render( ) - // The settled run heads with the command it ran; the live row is separate. - const settledTool = screen.getByText('pnpm test') - const activity = screen.getByText('Working…') - expect(activity.textContent).not.toBe(settledTool.textContent) - expect(activity).not.toHaveTextContent('shell') - expect(activity).not.toHaveTextContent('pnpm test') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( - 'animate-spin' + expect(container.querySelector('[data-native-chat-ask-row="awaiting"]')).toHaveTextContent( + /^Awaiting user input$/ ) + expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() + expect(screen.getByText('Working for 5s')).toBeInTheDocument() }) // The run is the turn's trailing one, so it stays live between calls and only @@ -286,42 +277,6 @@ describe('NativeChatMessageList turn indicator', () => { expect(liveRun?.querySelector('.lucide-check')).toBeInTheDocument() }) - it('keeps bridge chats on the legacy activity chrome', () => { - render( - - ) - - expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() - expect(screen.queryByText('Running sleep 5')).toBeNull() - expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) - }) - it('replaces a bridge ask row and settles it from the FIFO tool result', () => { const user = { id: 'bridge-user', @@ -355,7 +310,6 @@ describe('NativeChatMessageList turn indicator', () => { isWorking={false} expandSignal={false} fontScale={1} - showTurnStatus={false} /> ) @@ -382,7 +336,6 @@ describe('NativeChatMessageList turn indicator', () => { isWorking={false} expandSignal={false} fontScale={1} - showTurnStatus={false} /> ) @@ -655,92 +608,144 @@ describe('NativeChatMessageList turn indicator', () => { expect(screen.getByText('Working for 0s')).toBeInTheDocument() }) - it('keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn', () => { - const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ - id, - role: 'user', - blocks: [{ type: 'text', text }], - timestamp: 1, - source: 'transcript' - }) - const messages = [ - prompt('user-a', 'Run four sleeps'), - { - id: 'tool-a', - role: 'assistant' as const, - blocks: [ - { - type: 'tool-call' as const, - name: 'Bash', - input: { command: 'sleep 15' }, - state: 'running' as const - } - ], - timestamp: 2, - source: 'transcript' as const - }, - prompt('user-b', 'Also say banana') - ] - const list = (props: Partial>) => ( - - ) - // B was sent while A's turn runs: the host still names A as the running turn's opener. - const { rerender } = render( - list({ - workingStartedAt: Date.now() - 5000, - settledTurns: new Map(), - activeTurnOpenedBy: 'user-a' + it.each([ + ["a host that states each row's turn", true], + ['a host that states none', false] + ])( + 'keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn, on %s', + (_, scoped) => { + const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' }) - ) - const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') - expect(screen.getAllByText(/Working for/)).toHaveLength(1) - expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) + const messages = [ + prompt('user-a', 'Run four sleeps'), + { + id: 'tool-a', + role: 'assistant' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' as const + } + ], + timestamp: 2, + source: 'transcript' as const + }, + prompt('user-b', 'Also say banana') + ] + // B was handed over while A's turn runs, so a host that states scopes puts it in that turn. + const t1: AgentJournalTurnScope = { kind: 'turn', turnItemId: 't1' } + const thread: AgentJournalTurnScope = { kind: 'thread' } + const row = ( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence, + observedAt: sequence, + body, + ...(scoped ? { turnScope } : {}) + }) + const journal = (t1State: 'running' | 'completed', t2State?: 'running' | 'completed') => [ + row('user-a', 1, { kind: 'message', role: 'user', blocks: [] }, thread), + row('t1', 2, { kind: 'turn', turnId: 't1', state: t1State, userItemId: 'user-a' }, thread), + row('tool-a', 3, { kind: 'message', role: 'assistant', blocks: [] }, t1), + row('user-b', 4, { kind: 'message', role: 'user', blocks: [] }, t1), + ...(t2State + ? [ + row( + 't2', + 5, + { kind: 'turn', turnId: 't2', state: t2State, userItemId: 'user-b' }, + thread + ) + ] + : []) + ] + const list = (props: Partial>) => ( + + ) + // B was sent while A's turn runs: the host's record still names A as the running turn's opener. + const { rerender } = render( + list({ + workingStartedAt: Date.now() - 5000, + settledTurns: new Map(), + journalItems: journal('running') + }) + ) + const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') + expect(screen.getAllByText(/Working for/)).toHaveLength(1) + expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) - // B's own turn opens: A settles in place and B counts from A's end. - rerender( - list({ - workingStartedAt: Date.now() - 1000, - settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), - activeTurnOpenedBy: 'user-b' - }) - ) - expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) - const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') - expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) + // A settles in place: under its prompt, above B. A's tool work is A's on either host (read + // from its scope, or from journal order), so the settled bar becomes that work's fold toggle. + const expectSettledUnderA = () => { + const settledA = screen + .getByText('Worked for 17s') + .closest('[data-native-chat-turn-status]') + expect(screen.getByText('Run four sleeps').compareDocumentPosition(settledA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(settledA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + } - rerender( - list({ - isWorking: false, - session: { ...session, status: 'ready', messages }, - workingStartedAt: null, - settledTurns: new Map([ - ['user-a', { startedAt: 1, workedSeconds: 17 }], - ['user-b', { startedAt: 2, workedSeconds: 51 }] - ]), - activeTurnOpenedBy: null - }) - ) - expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) - expect( - screen - .getByText('Also say banana') - .compareDocumentPosition(screen.getByText('Worked for 51s')) - ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(screen.queryByText(/Working for/)).toBeNull() - }) + // B's own turn opens: A settles in place and B counts from A's end. + rerender( + list({ + workingStartedAt: Date.now() - 1000, + settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), + journalItems: journal('completed', 'running') + }) + ) + expectSettledUnderA() + const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') + expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + + rerender( + list({ + isWorking: false, + session: { ...session, status: 'ready', messages }, + workingStartedAt: null, + settledTurns: new Map([ + ['user-a', { startedAt: 1, workedSeconds: 17 }], + ['user-b', { startedAt: 2, workedSeconds: 51 }] + ]), + journalItems: journal('completed', 'completed') + }) + ) + expectSettledUnderA() + expect( + screen + .getByText('Also say banana') + .compareDocumentPosition(screen.getByText('Worked for 51s')) + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.queryByText(/Working for/)).toBeNull() + } + ) it("uses the completed caret to expand that turn's tool details", () => { const startedAt = Date.now() - 3000 diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx new file mode 100644 index 00000000000..b01675c63a7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -0,0 +1,484 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} +const say = ( + itemId: string, + role: 'user' | 'assistant', + text: string, + scope: AgentJournalTurnScope = THREAD +) => item(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, scope) +const work = (itemId: string, scope: AgentJournalTurnScope) => + item( + itemId, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }] + }, + scope + ) +const settledTurn = (itemId: string, userItemId: string, seconds: number, start: number) => + item(itemId, { + kind: 'turn', + turnId: itemId, + state: 'completed', + outcome: 'success', + userItemId, + startedAt: start, + completedAt: start + seconds * 1000 + }) + +/** "List three fruits", answered and settled in 4s. */ +function fruitTurn(): AgentJournalRenderItem[] { + return [ + say('u1', 'user', 'List three fruits'), + settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + work('t1-work', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] +} + +function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + { + isWorking = false, + workingStartedAt = null, + hostTiming = true + }: { isWorking?: boolean; workingStartedAt?: number | null; hostTiming?: boolean } = {} +): React.JSX.Element { + return ( + + ) +} + +function renderJournal( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + isWorking = false +): void { + render(journalList(items, submissions, { isWorking })) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +describe('NativeChatMessageList turns from the turn record', () => { + it('gives /compact its own turn and result, and leaves the turn before it untouched', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + renderJournal(items, [ + { + clientMessageId: 'cmd-1', + fence: 1, + payloadFingerprint: 'f', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: 12_000 + } + ]) + + expect(screen.getByText('Apple, banana, cherry.')).toBeInTheDocument() + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + const compact = screen.getByText('/compact') + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 2s') + expect(follows(compact, previousStatus)).toBe(true) + expect(follows(ownStatus, compact)).toBe(true) + expect(screen.getByRole('separator', { name: 'Context compacted' })).toBeInTheDocument() + }) + + it('draws a message waiting behind /compact after the compaction, never above it', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + // Accepted while the command ran, so written before the command's result. + say(held, 'user', 'Say DONE'), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: + handedOverAt === undefined && clientMessageId === 'held' ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + // The command settled; the loop has not yet handed the held message over. + renderJournal(items, [submission('cmd-1', 9_500), submission('held')]) + + expect( + follows( + screen.getByText('Say DONE'), + screen.getByRole('separator', { name: 'Context compacted' }) + ) + ).toBe(true) + }) + + it('keeps the row that says why a turn stopped visible in its folded turn', () => { + const items = [ + ...fruitTurn(), + item( + 'exit', + { kind: 'status', text: 'The agent exited unexpectedly.', tone: 'error' }, + inTurn('t1') + ) + ] + renderJournal(items) + + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + expect(screen.getByText('The agent exited unexpectedly.')).toBeInTheDocument() + }) + + it('folds a turn the provider resumed on its own under its own status', () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + // Claude keys a resumed turn's record on itself: no message opened it. + settledTurn(wake, wake, 9, 20_000), + say('wake-narration', 'assistant', 'The background build finished.', inTurn(wake)), + work('wake-work', inTurn(wake)), + say('wake-answer', 'assistant', 'All tests pass now.', inTurn(wake)) + ] + renderJournal(items) + + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 9s') + expect(follows(ownStatus, screen.getByText('Apple, banana, cherry.'))).toBe(true) + expect(follows(ownStatus, previousStatus)).toBe(true) + expect(screen.queryByText('The background build finished.')).toBeNull() + expect(screen.getByText('All tests pass now.')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Toggle turn details' })).toHaveLength(2) + }) + + it("keeps the settled turn's duration while a turn the provider opened runs after it", () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + item(wake, { + kind: 'turn', + turnId: wake, + state: 'running', + userItemId: wake, + startedAt: 20_000 + }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + renderJournal(items, [], true) + + expect(screen.getByText('Worked for 4s')).toBeInTheDocument() + expect(screen.getByText('Checking the background build.')).toBeInTheDocument() + }) + + it('clocks a turn the provider opened under its own key, not the user turn before it', () => { + const wake = 'wake' + const ask = say('u1', 'user', 'List three fruits') + const answer = (state: 'running' | 'completed') => [ + ask, + item('t1', { kind: 'turn', turnId: 't1', state, userItemId: 'u1' }), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] + // No host durations: this client's own clock is all the rows have. + const local = { hostTiming: false } + const { rerender } = render( + journalList(answer('running'), [], { + ...local, + isWorking: true, + workingStartedAt: Date.now() - 7_000 + }) + ) + rerender(journalList(answer('completed'), [], local)) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + + const woken = [ + ...answer('completed'), + item(wake, { kind: 'turn', turnId: wake, state: 'running', userItemId: 'claude:wake' }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + rerender(journalList(woken, [], { ...local, isWorking: true, workingStartedAt: Date.now() })) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + }) + + it('draws a message waiting behind a running /compact after its live activity', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'running', + userItemId: compactEntry, + startedAt: 10_000 + }), + say(held, 'user', 'Say DONE') + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: handedOverAt === undefined ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + const { container } = render( + journalList(items, [submission('cmd-1', 9_500), submission('held')], { + isWorking: true, + workingStartedAt: 10_000 + }) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('/compact'))).toBe(true) + expect(follows(screen.getByText('Say DONE'), activity!)).toBe(true) + }) + + it.each(['settled', 'running'] as const)( + "a queued card Steered into a %s turn joins it, under the opener's bar, with no bar of its own", + (phase) => { + // Steer hands the draft over under a fresh submission id, linked by `queuedMessageId`; the + // host scopes its row to the turn it joined. + const steer = agentJournalSubmissionKey('handed-off') + const running = phase === 'running' + const items = [ + say('u1', 'user', 'List three fruits'), + running + ? item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 1_000 + }) + : settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + say(steer, 'user', 'Make it four', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry, date.', inTurn('t1')) + ] + const { container } = render( + journalList( + items, + [ + { + clientMessageId: 'handed-off', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 2_000, + resolvedAt: 2_100, + queuedMessageId: 'draft-1' + } + ], + running ? { isWorking: true, workingStartedAt: 1_000 } : {} + ) + ) + + const bars = container.querySelectorAll('[data-native-chat-turn-status]') + expect(bars).toHaveLength(1) + const opener = screen.getByText('List three fruits') + const steered = screen.getByText('Make it four') + expect(follows(bars[0]!, opener)).toBe(true) + expect(follows(steered, bars[0]!)).toBe(true) + expect(follows(screen.getByText('Apple, banana, cherry, date.'), steered)).toBe(true) + } + ) + + it("keeps a steer on its way into an ordinary running turn above that turn's activity", () => { + const steer = agentJournalSubmissionKey('steer') + const items = [ + say('u1', 'user', 'List three fruits'), + item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 10_000 + }), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + say(steer, 'user', 'Make it four') + ] + const { container } = render( + journalList( + items, + [ + { + clientMessageId: 'steer', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 10_500, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 10_000 } + ) + ) + + // Handed over within moments, where it already is: it must not cross the activity line to wait. + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Make it four'))).toBe(true) + }) + + it('keeps a message whose own start is pending above the activity that start reports', () => { + const waiting = agentJournalSubmissionKey('first') + const { container } = render( + journalList( + [say(waiting, 'user', 'Hello there')], + [ + { + clientMessageId: 'first', + fence: 1, + payloadFingerprint: 'first', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 9_000 } + ) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Hello there'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 7097cb87aa4..bcecab05bc6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -204,41 +204,3 @@ describe('a user message that did not go through', () => { expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) }) - -describe("MessageRow — a subagent's row speaks as that subagent", () => { - function renderAgentRow(agentId: string | undefined, subagentLabel?: string) { - return render( - - ) - } - - it('names the subagent that wrote the row', () => { - renderAgentRow('task-1', 'explore the lane') - expect( - screen.getByRole('note', { name: 'Written by subagent explore the lane' }) - ).toBeInTheDocument() - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() - }) - - it('still marks the row as a subagent when no loaded roster names it', () => { - renderAgentRow('task-9') - expect(screen.getByRole('note', { name: 'Subagent' })).toBeInTheDocument() - }) - - it("adds nothing to the session's own row", () => { - renderAgentRow(undefined, 'explore the lane') - expect(screen.queryByRole('note')).toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 59e70cfc7e1..9275552605d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useRef } from 'react' -import { Bot, Goal, RotateCcw } from 'lucide-react' +import { Goal, RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' import CommentMarkdown, { type CommentMarkdownLinkClickHandler @@ -11,8 +11,6 @@ import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types' import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' -import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' -import { NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY } from '../../../../shared/native-chat-subagent-attribution' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' @@ -24,6 +22,10 @@ import { ProviderFrameRow } from './NativeChatTranscriptChrome' import type { NativeChatDiffReveal } from './native-chat-turn-diffs' +import type { + NativeChatSubagentDisclosure, + NativeChatSubagentRosterState +} from './native-chat-subagent-sections' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' /** What a user message says under it when it did not go through, with its own Retry when the @@ -47,9 +49,10 @@ export const MessageRow = memo(function MessageRow({ onLinkClick, allowFileUriLinks = false, deliveryNotice, - structuredActivityUi = true, folded = false, - subagentLabel, + subagentRoster, + subagentDisclosure, + inSubagentSection = false, runtimeContext }: { message: NativeChatMessage @@ -65,11 +68,13 @@ export const MessageRow = memo(function MessageRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotice?: NativeChatDeliveryNotice - structuredActivityUi?: boolean /** Behind a folded turn: the row keeps only what outlives the turn. */ folded?: boolean - /** The roster's name for the subagent that wrote this row, when one names it. */ - subagentLabel?: string + /** On a roster row: its list's state and the subagents whose rows open below it. */ + subagentRoster?: NativeChatSubagentRosterState + subagentDisclosure?: NativeChatSubagentDisclosure + /** Inside a subagent's section, whose border has to reach past the row's controls. */ + inSubagentSection?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element | null { const rowRef = useRef(null) @@ -198,24 +203,6 @@ export const MessageRow = memo(function MessageRow({ // Plain assistant prose is the copyable unit; reasoning/system asides stay // chrome-free. Controls reveal on hover/keyboard focus and stay visible on touch. const showControls = !isReasoning && !isSystem && markdown.length > 0 - // A subagent's row sits where it happened but speaks as that subagent, never as - // the agent the reader is talking to. - const subagentName = - agentJournalItemSubagentId(message) === null - ? null - : (subagentLabel ?? - translate( - 'components.native-chat.subagents.unnamed', - NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed - )) - const subagentCaption = - subagentLabel === undefined - ? subagentName - : translate( - 'components.native-chat.subagents.writtenBy', - NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.writtenBy, - { value0: subagentLabel } - ) return (
- {subagentName !== null ? ( -
- - - {subagentName} - -
- ) : null} ) : null} @@ -278,7 +253,11 @@ export const MessageRow = memo(function MessageRow({ markdown={markdown} timestamp={message.timestamp} onScrollToTop={scrollToTop} - className="mt-1 -mb-5 w-fit select-none transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100" + className={cn( + 'mt-1 w-fit select-none transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100', + // They hang into the gap below; a section keeps them inside its border instead. + !inSubagentSection && '-mb-5' + )} /> ) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index ef4364b9cb1..9ed6c2eda02 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -2,7 +2,6 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AgentJournalItemBodySchema } from '../../../../shared/agent-session-journal-schemas' import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' import { MessageRow } from './NativeChatMessageRow' @@ -84,27 +83,3 @@ describe('notice rows', () => { expect(screen.getByText('Future readable text').parentElement?.querySelector('svg')).toBeNull() }) }) - -describe('old-reader compatibility', () => { - // Derive the prior status shape without its new optional hints. - const statusSchema = AgentJournalItemBodySchema.options.find( - (schema): schema is (typeof AgentJournalItemBodySchema.options)[5] => - schema.shape.kind.value === 'status' - )! - const oldStatusSchema = statusSchema.omit({ tone: true, presentation: true }) - it.each([ - { presentation: 'compaction' }, - { presentation: 'plan-document' }, - { tone: 'warning' }, - { tone: 'error' }, - { tone: 'notice' }, - { tone: 'future-tone', presentation: 'future-presentation' } - ])('accepts new metadata and still renders text with an old reader: %j', (metadata) => { - const body = { kind: 'status', text: 'Text survives version skew', ...metadata } - expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true) - const oldBody = oldStatusSchema.parse(body) as AgentJournalStatusItem - expect(oldBody).toEqual({ kind: 'status', text: body.text }) - renderStatus(oldBody) - expect(screen.getByText(body.text)).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx new file mode 100644 index 00000000000..c37fd3127b3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -0,0 +1,214 @@ +import { + AlertCircle, + CornerDownRight, + ListEnd, + MoreHorizontal, + Pencil, + Send, + Trash2 +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { translate } from '@/i18n/i18n' +import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' +import { isMacPlatform } from './native-chat-shortcut' +import type { QueuedMessageCard } from './structured-agent-session-queued-cards' + +/** The visible caption under the text; the default waiting hold needs none. */ +export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { + switch (card.hold) { + case 'returned': { + // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. + const reason = card.returnedReason ?? null + // A consumed draft whose submission a Stop withdrew is not a failure of the + // message — say what happened rather than "not sent". + if ( + classifyDispatchRejection({ reason, rejection: card.returnedRejection }).category === + 'withdrawn' + ) { + return translate( + 'components.native-chat.queuedMessages.withdrawnHold', + 'Stopped before it was sent' + ) + } + return agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason }, + // The card's own Send is the retry, so the words leave out sending again. + { retryControl: true }, + readAgentSessionFailureFact(card.returnedRejection) + ) + ) + } + case 'paused': + // A card's own hold; the queue's pause is the list's header. Markers localize, and an absent + // or unknown one (newer host) is a plain pause, never shown raw. + if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { + return translate( + 'components.native-chat.queuedMessages.pausedSendFailed', + "Couldn't send — press Send to retry." + ) + } + return translate('components.native-chat.queuedMessages.paused', 'Paused') + case 'behind-returned': + return translate( + 'components.native-chat.queuedMessages.behindReturnedHold', + 'Waiting — a message ahead needs attention' + ) + case 'awaiting-answer': + return translate( + 'components.native-chat.queuedMessages.awaitingAnswerHold', + 'Waiting for your answer' + ) + case 'turn': + case 'queue-paused': + // Plainly queued; a paused queue's header row carries the why. + return null + } +} + +/** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own + * or returned is not waiting on the turn, so its action and tooltip are plainly Send. */ +export function queuedMessageCardSendNow(card: QueuedMessageCard): { + /** Steer's ↳, or Send's paper plane. */ + steers: boolean + label: string + hint: string +} { + if (card.hold === 'paused' || card.hold === 'returned') { + return { + steers: false, + label: translate('components.native-chat.queuedMessages.send', 'Send'), + hint: translate('components.native-chat.queuedMessages.sendHint', 'Send this message now') + } + } + return { + steers: true, + label: translate('components.native-chat.queuedMessages.steer', 'Steer'), + hint: translate( + 'components.native-chat.queuedMessages.steerHint', + 'Submit without interrupting the model' + ) + } +} + +export function NativeChatQueuedMessageCard({ + card, + showsSteerShortcut, + onSteer, + onDelete, + onEdit, + onTurnOffQueueing +}: { + card: QueuedMessageCard + /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ + showsSteerShortcut: boolean + onSteer: () => void + onDelete: () => void + onEdit: () => void + onTurnOffQueueing: () => void +}): React.JSX.Element { + const caption = queuedMessageCardCaption(card) + const returned = card.state === 'returned' + const sendNow = queuedMessageCardSendNow(card) + const isMac = isMacPlatform() + return ( +
  • + {returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? ( + + ) : ( + + )} +
    +

    + {card.text} +

    + {caption ? ( +

    + {caption} +

    + ) : null} +
    + + + + + + + {sendNow.hint} + {showsSteerShortcut ? ( + + ) : null} + + + + + + + + + {translate('components.native-chat.queuedMessages.delete', 'Delete')} + + + + + + + + + + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} + + + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + + +
  • + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx new file mode 100644 index 00000000000..c681a3902f9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -0,0 +1,454 @@ +// @vitest-environment happy-dom + +// The card stack above the composer: an accessible, labeled live list whose +// rows expose Steer/Send, Delete, and the Edit / Turn-off-queueing menu, with +// captions derived client-side per state. + +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + updateSettings: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('../../store', () => { + const state = { updateSettings: mocks.updateSettings } + const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) + +import { TooltipProvider } from '@/components/ui/tooltip' +import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' +import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' +import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' + +function renderList(owner: StructuredAgentSessionQueuedMessagesController) { + // The app root mounts the provider; tests supply the same context. + return render( + + + + ) +} + +function card(overrides: Partial & { messageId: string }): QueuedMessageCard { + return { + position: 1, + text: `text of ${overrides.messageId}`, + state: 'waiting', + hold: 'turn', + ...overrides + } +} + +function controller( + cards: QueuedMessageCard[], + pause: { reason: string } | null = null +): StructuredAgentSessionQueuedMessagesController & { + steer: ReturnType + remove: ReturnType + edit: ReturnType + resume: ReturnType +} { + return { + cards, + pause, + resume: vi.fn(async () => {}), + resuming: false, + steer: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + edit: vi.fn(async () => {}), + steerNewest: vi.fn(() => false) + } +} + +beforeEach(() => { + mocks.updateSettings.mockReset() +}) + +afterEach(cleanup) + +describe('NativeChatQueuedMessageList', () => { + it('renders only an empty live region when the host holds no drafts', () => { + const { container } = renderList(controller([])) + expect(screen.queryByRole('list')).toBeNull() + expect(container.querySelector('[aria-live="polite"]')?.childElementCount).toBe(0) + }) + + it('the first card appears inside a live region that was already mounted', () => { + const { container, rerender } = renderList(controller([])) + const region = container.querySelector('[aria-live="polite"]') + rerender( + + + + ) + expect(container.querySelector('[aria-live="polite"]')).toBe(region) + expect(region?.contains(screen.getByRole('list', { name: 'Queued messages' }))).toBe(true) + }) + + it('is a labeled list with one row per draft, in order', () => { + renderList( + controller([ + card({ messageId: 'draft-1', position: 1 }), + card({ messageId: 'draft-2', position: 2 }) + ]) + ) + screen.getByRole('list', { name: 'Queued messages' }) + const rows = screen.getAllByRole('listitem') + expect(rows).toHaveLength(2) + expect(rows[0]?.textContent).toContain('text of draft-1') + expect(rows[1]?.textContent).toContain('text of draft-2') + }) + + it('Steer and Delete are named buttons wired to their card', () => { + const owner = controller([ + card({ messageId: 'draft-1', position: 1 }), + card({ messageId: 'draft-2', position: 2 }) + ]) + renderList(owner) + fireEvent.click(screen.getAllByRole('button', { name: 'Steer' })[0]!) + expect(owner.steer).toHaveBeenCalledWith('draft-1') + fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1]!) + expect(owner.remove).toHaveBeenCalledWith('draft-2') + }) + + it.each(['Delete', 'Steer'])( + '%s hands focus to the composer once the focused card is gone', + async (name) => { + const focusComposer = vi.fn() + const owner = controller([card({ messageId: 'draft-1', position: 1 })]) + render( + + + + ) + const action = screen.getByRole('button', { name }) + action.focus() + fireEvent.click(action) + await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) + } + ) + + it('a returned card shows the stored reason and offers Send instead of Steer', () => { + const owner = controller([ + card({ + messageId: 'refused', + state: 'returned', + hold: 'returned', + // Internal marker: never shown verbatim, exactly as for rejected submissions. + returnedReason: 'host_restarted_before_delivery' + }) + ]) + renderList(owner) + expect(screen.getByRole('listitem').textContent).toContain('Your message was not sent.') + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(owner.steer).toHaveBeenCalledWith('refused') + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + }) + + it('a returned card is worded from its typed fact, exactly as a rejected submission', () => { + renderList( + controller([ + card({ + messageId: 'restarted', + state: 'returned', + hold: 'returned', + returnedReason: 'host_restarted_before_delivery', + returnedRejection: { kind: 'hostRestarted' } + }) + ]) + ) + const row = screen.getByRole('listitem') + expect(row.textContent).toContain('Orca restarted before this message was sent.') + expect(row.textContent).not.toContain('Your message was not sent.') + }) + + it('the words leave out sending again: the card offers its own Send', () => { + renderList( + controller([ + card({ + messageId: 'undelivered', + state: 'returned', + hold: 'returned', + returnedReason: 'not_delivered', + returnedRejection: { kind: 'notDelivered' } + }) + ]) + ) + const row = screen.getByRole('listitem') + expect(row.textContent).toContain('This message was not delivered.') + expect(row.textContent).not.toContain('Send it again') + }) + + it("a Stop's withdrawal is read from the fact, whatever sentence the reason carries", () => { + renderList( + controller([ + card({ + messageId: 'stopped', + state: 'returned', + hold: 'returned', + returnedReason: 'This message was withdrawn before the agent started it.', + returnedRejection: { kind: 'cancelled' } + }) + ]) + ) + expect(screen.getByRole('listitem').textContent).toContain('Stopped before it was sent') + }) + + it("a provider's own refusal words are shown verbatim", () => { + renderList( + controller([ + card({ + messageId: 'refused', + state: 'returned', + hold: 'returned', + returnedReason: 'The active turn cannot be steered during review.' + }) + ]) + ) + expect(screen.getByRole('listitem').textContent).toContain( + 'The active turn cannot be steered during review.' + ) + }) + + it('a paused queue shows one header row per reason above the cards, with Resume', () => { + const cases = [ + ['stopped', 'Queue paused because you interrupted'], + ['restarted', 'Queue paused because Orca restarted'], + ['cleared', 'Queue paused after you cleared the conversation'], + ['some-newer-reason', 'Queue paused'] + ] as const + for (const [reason, text] of cases) { + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason }) + const view = renderList(owner) + expect(view.container.textContent).toContain(text) + // The row sits above the list, not inside it: it is not a queued message. + expect(within(screen.getByRole('list')).queryByText(text)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Resume' })) + expect(owner.resume).toHaveBeenCalledTimes(1) + view.unmount() + } + }) + + it('no header row when the queue drains on its own, or when there are no cards', () => { + renderList(controller([card({ messageId: 'waiting' })], null)) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + cleanup() + const { container } = renderList(controller([], { reason: 'stopped' })) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + expect(container.textContent).toBe('') + }) + + it('no header row over cards Resume would not send: returned, held on their own, or behind', () => { + renderList( + controller( + [ + card({ messageId: 'refused', state: 'returned', hold: 'returned', returnedReason: null }), + card({ messageId: 'behind', hold: 'behind-returned', position: 2 }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) + ], + { reason: 'stopped' } + ) + ) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + }) + + it('Resume shows it is pending, keeps the full text for a truncated line, and hands focus back', async () => { + const focusComposer = vi.fn() + const owner = { + ...controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason: 'stopped' }), + resuming: true + } + render( + + + + ) + const resume = screen.getByRole('button', { name: 'Resume' }) + expect(resume).toHaveProperty('disabled', true) + expect(screen.getByTitle('Queue paused because you interrupted')).toBeTruthy() + cleanup() + const idle = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { + reason: 'stopped' + }) + render( + + + + ) + const enabled = screen.getByRole('button', { name: 'Resume' }) + enabled.focus() + fireEvent.click(enabled) + await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) + }) + + it('Steer carries the ↳ icon; a card leads with the queue glyph, a failed one with the alert', () => { + const { container } = renderList( + controller([ + card({ messageId: 'waiting' }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 2 }) + ]) + ) + const [waiting, failed] = screen.getAllByRole('listitem') + expect( + within(waiting!) + .getByRole('button', { name: 'Steer' }) + .querySelector('.lucide-corner-down-right') + ).not.toBeNull() + expect(waiting!.firstElementChild?.classList.contains('lucide-list-end')).toBe(true) + expect( + within(failed!).getByRole('button', { name: 'Send' }).querySelector('.lucide-send') + ).not.toBeNull() + expect(failed!.firstElementChild?.classList.contains('lucide-circle-alert')).toBe(true) + expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) + }) + + it('one bordered box holds the pause row and every card, rows divided, cards unbordered', () => { + renderList( + controller( + [ + card({ messageId: 'waiting', hold: 'queue-paused' }), + card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) + ], + { reason: 'stopped' } + ) + ) + const list = screen.getByRole('list') + const box = list.parentElement! + expect(box.className.split(' ')).toEqual( + expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card', 'divide-y']) + ) + // The pause row is the box's first row, outside the list; the list is its second. + expect(box.children).toHaveLength(2) + expect(box.firstElementChild?.textContent).toContain('Queue paused because you interrupted') + expect(box.lastElementChild).toBe(list) + expect(list.className.split(' ')).toContain('divide-y') + const rows = within(list).getAllByRole('listitem') + expect(rows).toHaveLength(3) + for (const row of rows) { + expect(row.className.split(' ')).not.toContain('border') + expect(row.className.split(' ')).not.toContain('rounded-md') + } + // Captions still render on their own rows. + expect(rows[2]?.textContent).toContain("Couldn't send — press Send to retry.") + }) + + it('without a pause the box holds only the list, one card or many', () => { + for (const count of [1, 4]) { + const view = renderList( + controller( + Array.from({ length: count }, (_, index) => + card({ messageId: `draft-${index}`, position: index }) + ) + ) + ) + const list = screen.getByRole('list') + expect(list.parentElement?.children).toHaveLength(1) + expect(within(list).getAllByRole('listitem')).toHaveLength(count) + view.unmount() + } + }) + + it('the Steer tooltip spaces its hint from the shortcut chips', async () => { + renderList(controller([card({ messageId: 'newest' })])) + fireEvent.focus(screen.getByRole('button', { name: 'Steer' })) + const hint = await screen.findAllByText('Submit without interrupting the model') + const group = hint[0]!.parentElement! + expect(group.tagName).toBe('SPAN') + expect(group.className).toBe('flex items-center gap-2') + expect(group.children).toHaveLength(2) + }) + + it('cards keep Steer, Delete and More actions while the queue is paused', () => { + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { + reason: 'stopped' + }) + renderList(owner) + const row = screen.getByRole('listitem') + expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() + expect(within(row).getByRole('button', { name: 'Delete' })).toBeTruthy() + expect(within(row).getByRole('button', { name: 'More actions' })).toBeTruthy() + expect(row.querySelectorAll('p')).toHaveLength(1) + fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + expect(owner.steer).toHaveBeenCalledWith('waiting') + }) + + it('only a card still waiting on the turn promises to skip the wait', () => { + for (const hold of ['turn', 'awaiting-answer', 'behind-returned'] as const) { + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ + steers: true, + label: 'Steer', + hint: 'Submit without interrupting the model' + }) + } + for (const hold of ['paused', 'returned'] as const) { + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ + steers: false, + label: 'Send', + hint: 'Send this message now' + }) + } + }) + + it("the host's pause and withdrawal markers localize instead of rendering raw", () => { + renderList( + controller([ + card({ + messageId: 'failed-consume', + hold: 'paused', + pausedReason: 'send_failed' + }), + card({ + messageId: 'stopped', + state: 'returned', + hold: 'returned', + position: 2, + // A cancellation confirmed after Stop settled renders as an ordinary card. + returnedReason: 'provider_cancelled_before_start' + }) + ]) + ) + const rows = screen.getAllByRole('listitem') + expect(rows[0]?.textContent).toContain("Couldn't send — press Send to retry.") + expect(rows[0]?.textContent).not.toContain('send_failed') + expect(rows[1]?.textContent).toContain('Stopped before it was sent') + // Still a normal returned card: Send and Delete stay offered. + expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) + }) + + it('an absent or unknown pause marker reads as a plain pause, never raw', () => { + renderList( + controller([ + card({ messageId: 'future', hold: 'paused', pausedReason: 'some_newer_marker' }), + card({ messageId: 'bare', hold: 'paused', position: 2 }) + ]) + ) + for (const row of screen.getAllByRole('listitem')) { + expect(row.querySelectorAll('p')[1]?.textContent).toBe('Paused') + } + expect(screen.getAllByRole('listitem')[0]?.textContent).not.toContain('some_newer_marker') + }) + + it('a draft behind a returned card says a message ahead needs attention', () => { + renderList(controller([card({ messageId: 'behind', hold: 'behind-returned' })])) + expect(screen.getByRole('listitem').textContent).toContain( + 'Waiting — a message ahead needs attention' + ) + }) + + it('the menu offers Edit message and Turn off queueing', async () => { + const owner = controller([card({ messageId: 'draft-1' })]) + renderList(owner) + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit message' })) + expect(owner.edit).toHaveBeenCalledWith('draft-1') + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) + expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx new file mode 100644 index 00000000000..17223e0194c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -0,0 +1,123 @@ +import { useRef } from 'react' +import { Pause, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useAppStore } from '../../store' +import { translate } from '@/i18n/i18n' +import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' +import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' + +/** + * Host-held drafts stacked between the transcript and the composer — never in + * the transcript: a draft only becomes a bubble once the host consumes it into + * a submission. The live region stays mounted while empty, so the first card is announced. + */ +export function NativeChatQueuedMessageList({ + controller, + focusComposer +}: { + controller: StructuredAgentSessionQueuedMessagesController + /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ + focusComposer?: () => void +}): React.JSX.Element { + const updateSettings = useAppStore((store) => store.updateSettings) + const queueRef = useRef(null) + const { cards } = controller + const newest = cards.at(-1) + // A pause over cards Resume would not send (returned, held on their own, or behind a returned + // one) offers nothing to press. + const pause = cards.some((card) => card.hold === 'queue-paused') ? controller.pause : null + // Only when focus was on the queue (a card, or Resume) — never pull it from wherever the user + // moved on to. + const refocusAfter = (action: Promise): void => { + void action.then(() => { + const active = document.activeElement + if (!active || active === document.body || queueRef.current?.contains(active)) { + focusComposer?.() + } + }) + } + return ( +
    + {cards.length > 0 ? ( +
    + {/* One box: the pause row, when shown, is its first row, and each card a row below it. */} +
    + {pause ? ( + refocusAfter(controller.resume())} + /> + ) : null} +
      + {cards.map((card) => ( + refocusAfter(controller.steer(card.messageId))} + onDelete={() => refocusAfter(controller.remove(card.messageId))} + onEdit={() => refocusAfter(controller.edit(card.messageId))} + onTurnOffQueueing={() => void updateSettings({ nativeChatQueueFollowUps: false })} + /> + ))} +
    +
    +
    + ) : null} +
    + ) +} + +/** Why the queue sends nothing on its own. An unknown reason (a newer host) is a plain pause. */ +function queuePauseText(pause: { reason: string }): string { + switch (pause.reason) { + case 'stopped': + return translate( + 'components.native-chat.queuedMessages.queuePausedStopped', + 'Queue paused because you interrupted' + ) + case 'restarted': + return translate( + 'components.native-chat.queuedMessages.queuePausedRestarted', + 'Queue paused because Orca restarted' + ) + case 'cleared': + return translate( + 'components.native-chat.queuedMessages.queuePausedCleared', + 'Queue paused after you cleared the conversation' + ) + default: + return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') + } +} + +function NativeChatQueuePauseRow({ + pause, + resuming, + onResume +}: { + pause: { reason: string } + resuming: boolean + onResume: () => void +}): React.JSX.Element { + const text = queuePauseText(pause) + return ( +
    + +

    + {text} +

    + +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index fb432e1e5b4..786ade25ebc 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -11,12 +11,12 @@ import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatC import { useNativeChatFontScale } from './use-native-chat-font-scale' import { useNativeChatCanSend } from './use-native-chat-can-send' import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' +import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card' import { NativeChatEmptyState } from './NativeChatEmptyState' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { - shouldClearNativeChatWorkingSuppression, - shouldShowNativeChatWorking -} from './native-chat-working-suppression' +import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-suppression' +import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn' +import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing' import { appendPendingSendCache, launchPromptAsMessage, @@ -251,6 +251,11 @@ export function NativeChatResolvedView({ paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, sessionAfterCommandBoundaries.messages ) + const promptCard = useNativeChatInteractivePromptCard({ + paneKey, + messages: sessionAfterCommandBoundaries.messages, + transcriptSettled: session.readPhase === 'ready' + }) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -313,11 +318,14 @@ export function NativeChatResolvedView({ previousWorkingEpochRef.current = null } }, [liveWorking, workingInterrupted, hookWorkingEpoch]) - const isWorking = shouldShowNativeChatWorking({ + const { isWorking, turnActive, awaitingInput } = resolveNativeChatTerminalTurn({ isConversation, working: liveWorking, - interrupted: workingInterrupted + hookAwaitingInput: session.hookAwaitingInput === true, + interrupted: workingInterrupted, + hasPromptCard: promptCard !== null }) + const turnTiming = useNativeChatTerminalTurnTiming(paneKey, session.messages, turnActive) const stopAgent = useCallback(() => { setWorkingInterrupted(true) @@ -399,11 +407,11 @@ export function NativeChatResolvedView({ diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx new file mode 100644 index 00000000000..97d8acc7d75 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx @@ -0,0 +1,333 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import type { AgentStatusEntry, AgentStatusPayload } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' + +// The transcript source and the composer are stubbed; the wire under test is +// store status -> resolved view -> the shared message list and prompt card. +const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null })) +vi.mock('./use-native-chat-retained-session', () => ({ + useNativeChatRetainedSession: () => retained.session +})) +vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: () => null })) + +const { NativeChatResolvedView } = await import('./NativeChatResolvedView') +const { useAppStore } = await import('../../store') +const { installNativeChatMessageListTestViewport } = + await import('./native-chat-message-list-test-viewport') + +const paneKey = 'tab-state:leaf-state' +let restoreViewport = (): void => {} + +const userTurn: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Rename the module' }], + timestamp: 1, + source: 'transcript' +} + +const askCall: NativeChatMessage = { + id: 'ask-1', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Which name?', + multiSelect: false, + options: [{ label: 'core' }, { label: 'base' }] + } + ] + } + } + ], + timestamp: 2, + source: 'transcript' +} + +const stepCall: NativeChatMessage = { + id: 'step-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm build' } }], + timestamp: 3, + source: 'transcript' +} + +// `status` and `hookAwaitingInput` are what the live session reconciles from the +// same hook row; the test states both so the view is fed a consistent session. +function transcript( + status: NativeChatLiveSession['status'], + hookAwaitingInput: boolean, + messages: NativeChatMessage[] = [userTurn] +): NativeChatLiveSession { + return { + messages, + status, + sessionId: 'session-state', + agent: 'claude', + hookAwaitingInput, + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } +} + +function setStatus(payload: Omit, age = 0): void { + useAppStore + .getState() + .setAgentStatus(paneKey, { prompt: 'Rename the module', agentType: 'claude', ...payload }) + const live = useAppStore.getState().agentStatusByPaneKey[paneKey] + if (!live) { + throw new Error('the store dropped the status row this test depends on') + } + useAppStore.setState((store) => ({ + agentStatusByPaneKey: { + ...store.agentStatusByPaneKey, + [paneKey]: { ...live, stateStartedAt: Date.now() - age } + } + })) +} + +function patchStatus(fields: Partial): void { + useAppStore.setState((store) => ({ + agentStatusByPaneKey: { + ...store.agentStatusByPaneKey, + [paneKey]: { ...store.agentStatusByPaneKey[paneKey]!, ...fields } + } + })) +} + +function renderPane(): void { + render( + + ) +} + +function liveActivityLine(): Element | null { + return document.querySelector('[data-native-chat-turn-activity]') +} + +beforeEach(() => { + restoreViewport = installNativeChatMessageListTestViewport() + useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} }) +}) + +afterEach(() => { + cleanup() + restoreViewport() + useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} }) +}) + +// A terminal-backed pane renders through the structured chat's own turn-status +// UI: the clock bar under the prompt, the live line at the tail, and the row a +// question uses when the agent waits on the reader. +describe('NativeChatResolvedView turn status', () => { + it('counts the working turn from the host-stamped epoch, with the live line at the tail', () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }, 75_000) + + renderPane() + + expect(screen.getByText('Working for 1m 15s')).toBeInTheDocument() + expect(liveActivityLine()).toHaveTextContent('Working…') + expect(document.querySelectorAll('.animate-bounce')).toHaveLength(0) + }) + + it.each(['waiting', 'blocked'] as const)( + 'says a %s agent waits on the reader when only its terminal shows the prompt', + (state) => { + retained.session = transcript('ready', true) + setStatus({ state }, 20_000) + + renderPane() + + // A whole phrase: there is no question to name after a colon. + expect(screen.getByText('Awaiting user input')).toBeInTheDocument() + expect(liveActivityLine()).toBeNull() + // The turn runs on behind the wait, as a structured turn does behind its card. + expect(screen.getByText('Working for 20s')).toBeInTheDocument() + } + ) + + it('lets the approval card speak for the wait', () => { + retained.session = transcript('ready', true) + setStatus({ + state: 'waiting', + interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf dist' } }) + }) + + renderPane() + + expect(screen.getByText('Allow Bash?')).toBeInTheDocument() + expect(screen.queryByText(/Awaiting user input/)).toBeNull() + expect(liveActivityLine()).toBeNull() + expect(screen.getByText('Working for 0s')).toBeInTheDocument() + }) + + it("keeps a pending question's transcript row awaiting while the agent waits", () => { + retained.session = transcript('ready', true, [userTurn, askCall]) + setStatus({ state: 'waiting' }) + + renderPane() + + // One row says it: the question's own, not a second one at the tail. + expect(document.querySelectorAll('[data-native-chat-ask-row="awaiting"]')).toHaveLength(1) + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.getAllByText('Which name?').length).toBeGreaterThan(0) + expect(screen.queryByText('Asked:')).toBeNull() + }) + + // Answering a terminal-only prompt means leaving the chat, which remounts it on return; a reload + // also starts the store with no state history. The host's stamp covers both. + it("counts a turn it first sees after a wait from the host's turn start, not the last state", () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }, 5_000) + patchStatus({ turnStartedAt: Date.now() - 90_000, stateHistory: [] }) + + renderPane() + + expect(screen.getByText('Working for 1m 30s')).toBeInTheDocument() + }) + + // The host recorded the turn's end, so a pane mounted after it (a switch to the terminal and + // back) folds it behind its duration instead of drawing it unfinished. + it('shows the host-ended latest turn with its host duration after a remount', () => { + retained.session = transcript('ready', false, [userTurn, stepCall]) + setStatus({ state: 'done' }) + // One clock read: the duration is the difference of these two stamps. + const now = Date.now() + patchStatus({ stateStartedAt: now - 10_000, turnStartedAt: now - 100_000 }) + + renderPane() + + expect(screen.getByText('Worked for 1m 30s')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toBeInTheDocument() + }) + + // The store's staleness drops a silent row after 30 minutes. A wait left unanswered that long + // must not read as a finished turn: nothing says it ended. + it('does not settle a turn the host went quiet on', () => { + retained.session = transcript('ready', true, [userTurn, stepCall]) + setStatus({ state: 'waiting' }, 60_000) + patchStatus({ turnStartedAt: Date.now() - 60_000 }) + + renderPane() + expect(screen.getByText('Working for 1m 0s')).toBeInTheDocument() + + // The live session reads no hook state once the row is stale, so the wait no longer holds the turn. + retained.session = transcript('ready', false, [userTurn, stepCall]) + act(() => { + patchStatus({ updatedAt: Date.now() - 31 * 60_000 }) + }) + + expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + expect(screen.getByText('pnpm build')).toBeInTheDocument() + }) + + it('folds finished turns from history behind their transcript duration', () => { + const at = Date.parse('2026-09-28T10:00:00.000Z') + retained.session = transcript('ready', false, [ + { ...userTurn, timestamp: at }, + { + id: 'answer-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Renamed.' }], + timestamp: at + 45_000, + source: 'transcript' + }, + { + id: 'user-2', + role: 'user', + blocks: [{ type: 'text', text: 'Now add tests' }], + timestamp: at + 100_000, + source: 'transcript' + } + ]) + setStatus({ state: 'done' }, 60_000) + + renderPane() + + expect(screen.getByText('Worked for 45s')).toBeInTheDocument() + // The latest turn has no recorded end, and this pane never watched it. + expect(screen.getAllByText(/Work(ing|ed) for/)).toHaveLength(1) + }) + + // The transcript draws no row for harness notices, so they cannot end a turn either. + it('keeps a running turn open across a harness notice injected mid-turn', () => { + const at = Date.parse('2026-09-28T10:00:00.000Z') + retained.session = transcript('working', false, [ + { ...userTurn, timestamp: at }, + { + id: 'progress-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Starting the build.' }], + timestamp: at + 10_000, + source: 'transcript' + }, + { + id: 'notice-1', + role: 'user', + blocks: [{ type: 'text', text: '\nb1' }], + timestamp: at + 20_000, + source: 'transcript' + }, + { + id: 'progress-2', + role: 'assistant', + blocks: [{ type: 'text', text: 'Build finished, checking output.' }], + timestamp: at + 25_000, + source: 'transcript' + } + ]) + setStatus({ state: 'working' }, 30_000) + + renderPane() + + expect(screen.getByText('Working for 30s')).toBeInTheDocument() + expect(screen.getByText('Starting the build.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + }) + + it('stays quiet on a settled turn it never watched', () => { + retained.session = transcript('ready', false) + setStatus({ state: 'done' }, 60_000) + + renderPane() + + expect(screen.queryByText(/Awaiting user input/)).toBeNull() + expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull() + expect(liveActivityLine()).toBeNull() + }) + + it('stays quiet when the pane has no status at all', () => { + retained.session = transcript('ready', false) + + renderPane() + + expect(screen.queryByText(/Awaiting user input/)).toBeNull() + expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull() + expect(liveActivityLine()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index f86fc972c22..45d30a31518 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -401,7 +401,7 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.queryByText(/not confirmed/)).toBeNull() }) - it.each(['catalog', 'agent-session'] as const)( + it.each(['catalog'] as const)( 'does not hedge a reported value on the %s transport', (transport) => { render( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx index c3c08f6647e..7b90b912a3f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx @@ -207,3 +207,36 @@ describe('NativeChatStructuredSession task strip above a pending prompt', () => ) }) }) + +describe('NativeChatStructuredSession queued messages above the task strip', () => { + afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() + }) + + it('stacks queued messages above the strip, so running shells and agents sit next to the composer', () => { + showStripAndGoal() + mocks.queuedCards = [ + { + messageId: 'draft-1', + position: 1, + text: 'Reply with the word banana.', + state: 'waiting', + hold: 'turn' + } + ] + render(sessionView()) + + const queued = document.querySelector('[data-queued-message-id="draft-1"]') + const strip = document.querySelector(STRIP) + if (!queued || !strip) { + throw new Error('expected both a queued message and the task strip') + } + expect(queued.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + // The strip still docks on the goal tab; the queue never sits between them. + expect(strip.nextElementSibling).toBe(document.querySelector(GOAL)) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 5689cf9689f..29dd7f4bdf0 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -1,6 +1,7 @@ import { forwardRef, useImperativeHandle, useRef } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' @@ -52,8 +53,7 @@ type StructuredSessionMessageListProps = { allowFileUriLinks?: boolean isVisible?: boolean onLinkClick?: (...args: unknown[]) => void - showTurnStatus?: boolean - showLiveTurnActivity?: boolean + awaitingInput?: 'shown' | 'unshown' | null isWorking?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } @@ -82,17 +82,19 @@ export function createStructuredSessionMocks() { readRefusal: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, - composerProps: null as null | { + composerProps: nullable<{ launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean - }, + onStop?: () => void + }>(), approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], journalItems: new Array(), respond: vi.fn<(...args: never[]) => unknown>(), cancel: vi.fn<(...args: never[]) => unknown>(), + stop: vi.fn<() => unknown>(), handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), pasteFromClipboard: vi.fn<(...args: never[]) => unknown>(), submissions: [] as unknown[], @@ -100,6 +102,8 @@ export function createStructuredSessionMocks() { showBackgroundTasks: false, isWorking: false, turnId: null as string | null, + // Unset: Stop follows the turn, as against an older host. + canStop: nullable(), supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -109,7 +113,12 @@ export function createStructuredSessionMocks() { hasOlder: false, loadingOlder: false, olderHistoryGeneration: 0, - loadOlder: vi.fn<() => Promise>() + loadOlder: vi.fn<() => Promise>(), + queuedCards: Array.of(), + queuedSteer: vi.fn<(messageId: string) => Promise>(async () => {}), + queuedRemove: vi.fn<(messageId: string) => Promise>(async () => {}), + queuedEdit: vi.fn<(messageId: string) => Promise>(async () => {}), + queuedSteerNewest: vi.fn<() => boolean>(() => false) } const moduleFactories = { @@ -179,6 +188,15 @@ export function createStructuredSessionMocks() { supportsStopAll: mocks.supportsBackgroundTaskStopAll }, turnId: mocks.turnId, + canStop: mocks.canStop ?? mocks.turnId !== null, + stop: mocks.stop, + queuedMessages: { + cards: mocks.queuedCards, + steer: mocks.queuedSteer, + remove: mocks.queuedRemove, + edit: mocks.queuedEdit, + steerNewest: mocks.queuedSteerNewest + }, threadGoal: mocks.threadGoal, cancel: mocks.cancel, stopBackgroundTask: (taskId?: string) => @@ -289,6 +307,7 @@ export function createStructuredSessionMocks() { mocks.journalItems = [] mocks.respond.mockReset() mocks.cancel.mockReset() + mocks.stop.mockReset() mocks.handlePasteEvent.mockReset() mocks.pasteFromClipboard.mockReset() mocks.submissions = [] @@ -296,6 +315,7 @@ export function createStructuredSessionMocks() { mocks.showBackgroundTasks = false mocks.isWorking = false mocks.turnId = null + mocks.canStop = null mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e766955b83e..e2c4c6ff030 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -27,6 +27,7 @@ vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' describe('NativeChatStructuredSession', () => { afterEach(() => { @@ -34,6 +35,22 @@ describe('NativeChatStructuredSession', () => { resetStructuredSessionMocks() }) + it('gives what a Stop withdrew back to the composer this pane shows', () => { + render( + + ) + const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1') + expect(mocks.composerProps).toMatchObject({ paneKey }) + expect(mocks.controllerProps).toMatchObject({ composerScopeKey: paneKey }) + }) + it('routes the launch draft and app-menu paste to the structured composer', () => { const draft = { tabId: 'structured-draft-tab', @@ -177,26 +194,22 @@ describe('NativeChatStructuredSession', () => { expect(mocks.loadOlder).toHaveBeenCalledOnce() }) - // Turn status and transcript image previews shipped Codex-first. Every - // structured session renders through the same list, so neither is agent-gated. - it.each(['codex', 'claude'] as const)( - 'renders the same structured transcript chrome for %s', - (agent) => { - render( - - ) + // Transcript image previews shipped Codex-first, and the runtime context they + // need comes from the tab rather than the agent, so it is never agent-gated. + it('hands the transcript the image runtime context', () => { + render( + + ) - expect(mocks.messageListProps?.showTurnStatus).toBe(true) - expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined() - } - ) + expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined() + }) it('suppresses live turn activity for a pending question without ending the turn', () => { mocks.isWorking = true @@ -216,7 +229,7 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps).toMatchObject({ isWorking: true, - showLiveTurnActivity: false + awaitingInput: 'shown' }) expect( document @@ -231,13 +244,13 @@ describe('NativeChatStructuredSession', () => { itemId: 'legacy-question-item', expectedRevision: 1 }) - expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) + expect(mocks.messageListProps?.awaitingInput).toBe('shown') mocks.promptItems = [] rerender(view()) expect(mocks.messageListProps).toMatchObject({ isWorking: true, - showLiveTurnActivity: true + awaitingInput: null }) expect(screen.getByTestId('structured-composer')).toBeTruthy() expect(mocks.composerProps?.isWorking).toBe(true) @@ -285,7 +298,7 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps).toMatchObject({ isWorking: true, - showLiveTurnActivity: false + awaitingInput: 'shown' }) expect(mocks.approvalCardProps?.approval.title).toBe('Allow command?') expect(screen.queryByTestId('structured-composer')).toBeNull() @@ -296,7 +309,7 @@ describe('NativeChatStructuredSession', () => { kind: 'option', optionId: 'allow' }) - expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) + expect(mocks.messageListProps?.awaitingInput).toBe('shown') act(() => mocks.approvalCardProps?.onCancel?.()) expect(mocks.cancel).toHaveBeenCalledWith('turn-approval', { @@ -368,6 +381,16 @@ describe('NativeChatStructuredSession', () => { expect(screen.getByRole('list', { name: 'Agents' })).toBeTruthy() }) + it('offers Stop before any turn opens when the controller can stop, and stops through it', () => { + mocks.canStop = true + render(claudeSessionView('structured-tab-pre-turn', 'session-pre-turn')) + + expect(mocks.composerProps?.isWorking).toBe(true) + act(() => mocks.composerProps?.onStop?.()) + expect(mocks.stop).toHaveBeenCalledOnce() + expect(mocks.cancel).not.toHaveBeenCalled() + }) + it('keeps the strip mounted through a running turn, with the turn owning the voice', () => { // The strip stands for work that OUTLIVES a turn, so `show` is true while // `isMonitoring` is false: mounted, but not speaking as the live indicator. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx deleted file mode 100644 index ffa8787e753..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import React from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -describe('NativeChatStructuredSession transport-unconfirmed sends', () => { - afterEach(() => { - cleanup() - resetStructuredSessionMocks() - }) - - it('retries an unconfirmed transport send and clears the delivery notice', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValueOnce({ - ok: true, - value: { - submission: { - clientMessageId: 'client-1', - dispatchState: 'accepted' - } - } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('hello', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - }) - - it('resends a transport-unconfirmed head so later messages are not wedged', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - expect(send?.('second', [])).toBe(true) - // The head is probed automatically, clears, and the queue drains. - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3), { timeout: 10000 }) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - }, 20000) - - it('probes the same operation without marking an explicit user retry', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 10000 }) - - const first = mocks.call.mock.calls[0]?.[2] as Record - const probe = mocks.call.mock.calls[1]?.[2] as Record - expect(probe.retryUnknown).toBeUndefined() - // Same operation id: both dedupe layers key off it. - expect((probe.envelope as { clientOperationId: string }).clientOperationId).toBe( - (first.envelope as { clientOperationId: string }).clientOperationId - ) - }, 20000) - - it('parks a host-confirmed unknown instead of probing it', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - // The host now reports an unresolved unknown: another replay is the user's call. - mocks.submissions = [ - { - clientMessageId: sent.envelope.clientOperationId, - fence: 1, - payloadFingerprint: 'fp', - dispatchState: 'unknown', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - ] - // Queue a second message purely to re-render so the effect observes the - // new submissions; it must stay wedged behind the parked head. - await act(async () => { - send?.('second', []) - }) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) - expect(mocks.call).toHaveBeenCalledOnce() - }, 20000) - - it('still probes while streaming batches rebuild the submissions array', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - const makeView = (): React.ReactElement => ( - - ) - const { rerender } = render(makeView()) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // Each batch mints a fresh submissions array for an unrelated message. An - // array-identity dependency restarts the backoff on every one of these, so a - // stream that outlasts the delay would never let the probe fire. - for (let index = 0; index < 12; index += 1) { - mocks.submissions = [ - { - clientMessageId: `other-${index}`, - fence: 1, - payloadFingerprint: 'fp', - dispatchState: 'accepted', - providerItemId: null, - reason: null, - submittedAt: index, - resolvedAt: index - } - ] - await act(async () => { - rerender(makeView()) - await new Promise((resolve) => setTimeout(resolve, 250)) - }) - } - - // Asserted with no trailing grace period: the probe must have fired *during* - // the stream, not after it went quiet. - expect(mocks.call).toHaveBeenCalledTimes(2) - }, 20000) - - it('restarts probe delay when the runtime target changes', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - const makeView = ( - target: { kind: 'local' } | { kind: 'environment'; environmentId: string } - ) => ( - - ) - const { rerender } = render(makeView({ kind: 'local' })) - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)) - }) - rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 600)) - }) - expect(mocks.call).toHaveBeenCalledOnce() - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 }) - }, 10000) - - it('never auto-probes an entry the user already force-retried', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - // Both the original send and the user's explicit Retry fail at the transport. - mocks.call.mockRejectedValue(new Error('socket closed')) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - // User retries with the same envelope and no legacy redelivery signal. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record | undefined - expect(forcedRequest?.retryUnknown).toBeUndefined() - - // That retry also failed at the transport. The probe must not repeat an - // explicit retry automatically. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) - expect(mocks.call).toHaveBeenCalledTimes(2) - }, 20000) - - it('does not hot-loop when the host answers pending', async () => { - mocks.mode = 'outbox' - mocks.call.mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'pending' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // A pending row parks the entry under the backoff instead of re-dispatching - // immediately. Without that, this window is an unbounded back-to-back RPC flood. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 2500)) - }) - expect(mocks.call.mock.calls.length).toBeLessThanOrEqual(3) - }, 20000) - - it('keeps probing past the old five-attempt budget', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValue(new Error('socket closed')) - vi.useFakeTimers({ shouldAdvanceTime: true }) - try { - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - - // Backoff is 1+2+4+8+16 = 31s for five probes, which was the old hard budget. - // Step past it; a seventh call proves the probe re-arms instead of giving up. - for (let step = 0; step < 12; step += 1) { - await act(async () => { - await vi.advanceTimersByTimeAsync(8_000) - }) - } - expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(7) - } finally { - vi.useRealTimers() - } - }, 30000) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index db4de629d01..23c072ccfdc 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -23,6 +23,8 @@ import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-a import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' +import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' +import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' @@ -43,8 +45,16 @@ export function NativeChatStructuredSession( const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) + const paneKey = useMemo( + () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), + [props.sessionId, props.tabId] + ) + // Chat-wide: absent means on; only an explicit off keeps mid-turn sends immediate. + const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false) const controller = useStructuredAgentSession({ ...props, + composerScopeKey: paneKey, + queueFollowUps, providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) @@ -62,10 +72,6 @@ export function NativeChatStructuredSession( id: string sequence: number } | null>(null) - const paneKey = useMemo( - () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), - [props.sessionId, props.tabId] - ) const rootRef = useRef(null) const composerRef = useRef(null) const paneCommands = useStructuredNativeChatPaneCommands({ @@ -272,6 +278,8 @@ export function NativeChatStructuredSession( + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} + { + composerRef.current?.focus() + }} + /> { - if (controller.turnId) { - void controller.cancel(controller.turnId) - } - }} + isWorking={controller.canStop} + onStop={() => void controller.stop()} + steerQueued={controller.queuedMessages.steerNewest} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 0e6994a7b8b..3f778fbd8ce 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({ messageListProps: null as null | { allowFileUriLinks?: boolean onLinkClick?: (...args: unknown[]) => void - showTurnStatus?: boolean runtimeContext?: unknown }, composerProps: null as null | { @@ -91,6 +90,13 @@ vi.mock('./use-structured-agent-session', async () => { backgroundTasks: mocks.backgroundTasks, turnId: null, cancel: vi.fn(), + queuedMessages: { + cards: [], + steer: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + edit: vi.fn(async () => {}), + steerNewest: () => false + }, stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, optionSnapshot: [ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx index 638ed4f054d..0f6e1e4c76c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx @@ -50,16 +50,6 @@ function renderStatus(startupPhase: 'starting' | 'ready' | null) { } describe('NativeChatStructuredSessionStatus', () => { - it('stays quiet through a normal startup', () => { - vi.useFakeTimers() - const view = renderStatus('starting') - act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - 1)) - expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() - view.rerender(statusElement('ready')) - act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) - expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() - }) - it('says the agent is still starting once startup runs long', () => { vi.useFakeTimers() renderStatus('starting') @@ -83,21 +73,6 @@ describe('NativeChatStructuredSessionStatus', () => { expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() }) - it('hides an old notice and gives a replacement child its full grace period', () => { - vi.useFakeTimers() - const view = render(statusElement('starting', 'session-1', 'child-1')) - act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) - expect(screen.getByText(/still starting/)).toBeInTheDocument() - view.rerender(statusElement('ready', 'session-1', 'child-1')) - act(() => vi.advanceTimersByTime(100)) - view.rerender(statusElement('starting', 'session-1', 'child-2')) - expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() - act(() => vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS)) - expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() - act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - STATUS_MIN_VISIBLE_MS)) - expect(screen.getByText(/still starting/)).toBeInTheDocument() - }) - it('resets for a replacement child even when no intermediate phase reaches the view', () => { vi.useFakeTimers() const view = render(statusElement('starting', 'session-1', 'child-1')) @@ -110,20 +85,6 @@ describe('NativeChatStructuredSessionStatus', () => { expect(screen.getByText(/still starting/)).toBeInTheDocument() }) - it('resets on an observed restart when an older host has no child identity', () => { - vi.useFakeTimers() - const view = renderStatus('starting') - act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) - view.rerender(statusElement('ready')) - act(() => vi.advanceTimersByTime(100)) - view.rerender(statusElement('starting')) - expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() - act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - 1)) - expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() - act(() => vi.advanceTimersByTime(1)) - expect(screen.getByText(/still starting/)).toBeInTheDocument() - }) - it('shows nothing about startup once the child is ready or the host has no word', () => { renderStatus('ready') expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx index c612af5b69c..ba0fa6219db 100644 --- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx @@ -2,12 +2,11 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatSubagentEntry, - NativeChatSubagentGroupBlock, - NativeChatSubagentState + NativeChatSubagentGroupBlock } from '../../../../shared/native-chat-types' import { NativeChatSubagentRun } from './NativeChatSubagentRun' import { NativeChatToolRun } from './NativeChatToolRun' @@ -95,38 +94,6 @@ describe('NativeChatSubagentRun', () => { expect(container.querySelector('.bg-destructive')).toBeNull() }) - // The QA defect: a mid-turn correction opened a new turn while three real - // children were still running, and the row relabelled every one of them - // `unverifiable` and flipped its headline to `Ran`. The children completed - // 57-87s later. A turn boundary says nothing about a child. - it('keeps a working child working once its turn is no longer the current one', () => { - render() - - const row = screen.getByRole('button') - expect(row).toHaveTextContent('working') - expect(row).not.toHaveTextContent('unverifiable') - expect(screen.getByText('Kicked off 1 subagent')).toBeInTheDocument() - }) - - it('reports the verdict a child lands after its turn ended', () => { - render( - - ) - - expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() - expect(screen.getByRole('button')).toHaveTextContent('completed') - }) - - // Only the writing host may claim loss of contact, and it writes that verdict - // into the row itself. The renderer draws it, and never infers it. - it('draws the unverifiable verdict the host recorded', () => { - render( - - ) - - expect(screen.getByRole('button')).toHaveTextContent('unverifiable') - }) - it('leads with the bot glyph, decorative beside the word that names the group', () => { const { container } = render( @@ -139,28 +106,6 @@ describe('NativeChatSubagentRun', () => { expect(screen.getByRole('button')).toHaveAccessibleName(/Kicked off 1 subagent/) }) - it('keeps the same glyph in every state, so a settling row never changes identity', () => { - const states: NativeChatSubagentState[] = [ - 'working', - 'idle', - 'completed', - 'failed', - 'stopped', - 'unverifiable' - ] - - for (const state of states) { - const { container } = render( - - ) - - expect(container.querySelectorAll('.lucide-bot')).toHaveLength(1) - expect(container.querySelector('.lucide-check')).toBeNull() - expect(container.querySelector('.lucide-users')).toBeNull() - cleanup() - } - }) - // The only aria-hidden span carrying text is the elapsed-clock wrapper: the // glyph's Bot is an and the status dots render empty. function hiddenTextSpans(container: HTMLElement): Element[] { @@ -198,6 +143,23 @@ describe('NativeChatSubagentRun', () => { expect(screen.getByRole('button')).toHaveTextContent('4s') }) + // A resumed child reopens in its original row with a fresh start; the idle + // time since its sibling stopped is not run time. + it('reads a child resumed after a long gap as its runs, not the gap', () => { + render( + + ) + + const row = screen.getByRole('button') + expect(row).toHaveTextContent('· 10s') + expect(row.textContent).not.toContain('5m') + }) + it('shows no duration for a child whose run length was never recorded', () => { render( { expect(row).toHaveTextContent('unverifiable') expect(row.textContent).not.toContain('·') }) + + // The provider can still say how a child whose host died ended, but not when. + it('shows no duration when a verdict reached a child with no recorded stop time', () => { + render( + + ) + + const row = screen.getByRole('button') + expect(row).toHaveTextContent('stopped') + expect(row.textContent).not.toContain('·') + }) }) describe('NativeChatToolRun with a spawn group', () => { @@ -312,4 +290,48 @@ describe('NativeChatToolRun with a spawn group', () => { expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() expect(screen.getByText('ls').closest('button')).toHaveTextContent('ls') }) + + it('lists every child as a plain line when none has rows of its own', () => { + const agents = [ + { id: 'a', label: 'read', state: 'completed' as const }, + { id: 'b', label: 'search', state: 'completed' as const }, + { id: 'c', label: 'list', state: 'completed' as const } + ] + render() + fireEvent.click(screen.getByRole('button', { name: /Ran 3 subagents/ })) + + expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + 'readcompleted', + 'searchcompleted', + 'listcompleted' + ]) + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('draws its entries through the first open one, whose rows the transcript draws next', () => { + const agents = [ + { id: 'a', label: 'read', state: 'completed' as const }, + { id: 'b', label: 'search', state: 'completed' as const }, + { id: 'c', label: 'list', state: 'completed' as const } + ] + render( + + ) + + expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + 'readcompleted', + 'searchcompleted' + ]) + expect(screen.getByRole('button', { name: /search/, expanded: true })).toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx index 1ea4852ab15..747383807d8 100644 --- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx @@ -8,10 +8,12 @@ import { summarizeSubagentGroup } from '../../../../shared/native-chat-subagent-summary' import type { + NativeChatSubagentEntry, NativeChatSubagentGroupBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types' import { formatNativeChatDuration } from './NativeChatWorkingStatus' +import { nativeChatSubagentEntryRuns } from './native-chat-subagent-sections' /** Compact token counts: the row shows scale, not an exact ledger. */ function formatSubagentTokens(tokens: number): string { @@ -111,7 +113,7 @@ function SubagentGlyph(): React.JSX.Element { /** `pulsing` is separate from `state` so a group that is still working can show * a failed sibling's colour without losing its in-flight cue. */ -function StatusDot({ +export function StatusDot({ state, pulsing = false }: { @@ -145,9 +147,77 @@ function SubagentElapsed({ return <>{formatNativeChatDuration(Math.max(0, (end - startedAt) / 1000))} } +/** A roster's entries, one per child. A child with a section is its head: its entry + * opens and closes it, and its rows follow the entry. */ +export function NativeChatSubagentEntries({ + agents, + sections, + onSetSectionOpen, + className +}: { + agents: readonly NativeChatSubagentEntry[] + sections?: ReadonlyMap + onSetSectionOpen?: (agentId: string, open: boolean) => void + className?: string +}): React.JSX.Element { + return ( +
      + {agents.map((agent) => { + const state = normalizeSubagentState(agent.state) + const sectionOpen = sections?.get(agent.id) + const entry = ( + <> + + + {agent.label} + + + {subagentStateLabel(state, 1, 1)} + {typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null} + + + ) + return ( +
    • + {sectionOpen === undefined ? ( +
      {entry}
      + ) : ( + + )} +
    • + ) + })} +
    + ) +} + /** One spawn group: how many children are working, their settled verdict, and - * the tokens they consumed. Deliberately flat — children are summarized here, - * never nested into the transcript as turns of their own. + * the tokens they consumed. Each child is one entry here; the child's own rows + * are never the conversation's. A child the session spawned shows them under its + * entry, open while the running session's newest output is part of delegating to + * it; the entry is where the reader overrides that. This row draws its entries + * through the first open one; the transcript draws that child's rows, then the rest. * * Every state is drawn exactly as the journal recorded it. Turn state is NOT * consulted: `spawn_agent` children outlive the turn that spawned them and keep @@ -157,11 +227,23 @@ function SubagentElapsed({ * when the provider goes away, and `staleSubagentRosterRevisions` on the next * journal open when the host itself died mid-flight. */ export function NativeChatSubagentRun({ - block + block, + open: heldOpen, + onSetOpen, + sections, + onSetSectionOpen }: { block: NativeChatSubagentGroupBlock + /** Whether the entries show, when the transcript holds that past this row's lifetime. */ + open?: boolean + onSetOpen?: (open: boolean) => void + /** The children whose rows open under their entries, and whether each is open. */ + sections?: ReadonlyMap + onSetSectionOpen?: (agentId: string, open: boolean) => void }): React.JSX.Element | null { - const [open, setOpen] = useState(false) + const [localOpen, setLocalOpen] = useState(false) + const open = heldOpen ?? localOpen + const setOpen = onSetOpen ?? setLocalOpen const agents = block.agents const summary = useMemo(() => summarizeSubagentGroup(agents), [agents]) if (summary.total === 0) { @@ -190,24 +272,24 @@ export function NativeChatSubagentRun({ const alertState = working ? summary.adverseState : null const alert = alertState === null ? null : subagentStateLabel(alertState, summary.adverseCount, summary.total) - // A child settled by the reopen reads `unverifiable` with no terminal stamp: - // it stopped being observable at an unknown moment. Measuring to `now` would - // report the time since the host died as how long the child ran, on a row that - // is not even counting. A sibling's stamp is no better: in a mixed group it - // would present that sibling's duration as the group's while a child's fate is - // still unknown. + // A child settled with no terminal stamp — swept by the reopen, or given the + // provider's verdict after that — stopped being observable at an unknown + // moment. Measuring to `now` would report the time since the host died as how + // long the child ran, on a row that is not even counting. A sibling's stamp is + // no better: in a mixed group it would present that sibling's duration as the + // group's while a child's run length is still unknown. const runLengthUnknown = agents.some( (agent) => - normalizeSubagentState(agent.state) === 'unverifiable' && typeof agent.settledAt !== 'number' + normalizeSubagentState(agent.state) !== 'working' && typeof agent.settledAt !== 'number' ) const clockStartedAt = - !runLengthUnknown && (working || summary.settledAt !== null) ? summary.startedAt : null + !runLengthUnknown && (working || summary.settledAt !== null) ? summary.clockStartedAt : null return (
    {open ? ( -
      - {agents.map((agent) => { - const state = normalizeSubagentState(agent.state) - return ( -
    • - - - {agent.label} - - - {subagentStateLabel(state, 1, 1)} - {typeof agent.tokens === 'number' - ? ` · ${formatSubagentTokens(agent.tokens)}` - : null} - -
    • - ) - })} -
    + ) : null}
    ) diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentSectionHead.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentSectionHead.tsx new file mode 100644 index 00000000000..400b839d471 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSubagentSectionHead.tsx @@ -0,0 +1,41 @@ +import { Bot, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { normalizeSubagentState } from '../../../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../../../shared/native-chat-types' +import { StatusDot } from './NativeChatSubagentRun' + +/** Names the subagent whose rows follow, and opens or closes them, where no loaded + * roster row holds its entry: its roster is off the page, or another subagent spawned + * it. An agent no roster names has no label or state to show, only that it is one. */ +export function NativeChatSubagentSectionHead({ + agentId, + entry, + expanded, + onSetOpen +}: { + agentId: string + entry: NativeChatSubagentEntry | undefined + expanded: boolean + onSetOpen: (agentId: string, open: boolean) => void +}): React.JSX.Element { + const state = entry === undefined ? null : normalizeSubagentState(entry.state) + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx b/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx index 588f392f713..17ffa840333 100644 --- a/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx @@ -64,12 +64,4 @@ describe('NativeChatTaskList', () => { expect(screen.getByText('No tasks')).toBeInTheDocument() expect(screen.getByLabelText('0 of 0 tasks completed')).toHaveTextContent('0/0') }) - - it('switches from full list to diff when earlier history supplies a predecessor', () => { - const { rerender } = render() - expect(screen.getByText('Test')).toBeInTheDocument() - rerender() - expect(screen.queryByText('Test')).toBeNull() - expect(screen.getByText('Started Write')).toBeInTheDocument() - }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx index eaf17c0ec0a..3eeee7b2652 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx @@ -81,19 +81,13 @@ describe('NativeChatThreadGoalBanner', () => { expect(onChange).toHaveBeenCalledWith({ kind: 'status', status: 'active' }) }) - it.each([ - ['blocked', 'Goal blocked'], - ['usageLimited', 'Goal limited'] - ] as const)( - 'offers resume on a %s goal, which the provider resumes like a paused one', - (status, label) => { - const { onChange } = renderBanner(goal({ status })) - expect(screen.getByText(label)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Pause goal' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Resume goal' })) - expect(onChange).toHaveBeenCalledWith({ kind: 'status', status: 'active' }) - } - ) + it('offers resume on a blocked goal, which the provider resumes like a paused one', () => { + const { onChange } = renderBanner(goal({ status: 'blocked' })) + expect(screen.getByText('Goal blocked')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Pause goal' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Resume goal' })) + expect(onChange).toHaveBeenCalledWith({ kind: 'status', status: 'active' }) + }) it('labels a goal whose token budget is spent and offers only clear', () => { renderBanner(goal({ status: 'budgetLimited' })) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx index 870bf39a409..b4257648efa 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx @@ -231,7 +231,7 @@ describe('NativeChatToolRun awaiting-input row', () => { /> ) - expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.getByText('Awaiting user input')).toBeInTheDocument() expect(screen.queryByText(/request_user_input/)).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 141ae739886..1b604822e8f 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -38,6 +38,10 @@ import { NativeChatTaskList } from './NativeChatTaskList' import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatBackgroundTaskRun } from './NativeChatBackgroundTaskRun' import { NativeChatSubagentRun } from './NativeChatSubagentRun' +import type { + NativeChatSubagentDisclosure, + NativeChatSubagentRosterState +} from './native-chat-subagent-sections' import { NativeChatToolRunIcon } from './NativeChatToolIcon' /** Stable empty default: a fresh array literal per render breaks memoization. */ @@ -53,12 +57,13 @@ export function NativeChatToolRun({ revealedDiff, onRevealDiff, subagentGroups = NO_SUBAGENT_GROUPS, + subagentRoster, + subagentDisclosure, backgroundTasks = NO_BACKGROUND_TASKS, expandSignal, activeTurnIsWorking, trailing, expandOverride, - structuredActivityUi = true, disclosureId, onLinkClick }: { @@ -69,6 +74,9 @@ export function NativeChatToolRun({ onRevealDiff?: (element: HTMLElement) => void /** Spawn-group rosters that belong with this run's activity, one row each. */ subagentGroups?: NativeChatSubagentGroupBlock[] + /** The rosters' list state, and their children whose rows open below this run. */ + subagentRoster?: NativeChatSubagentRosterState + subagentDisclosure?: NativeChatSubagentDisclosure /** Background tasks that belong with this run's activity, one row each. */ backgroundTasks?: NativeChatBackgroundTaskBlock[] /** Legacy view-level default; production native-chat entry points pass false. */ @@ -81,7 +89,6 @@ export function NativeChatToolRun({ * the agent has already moved past reads as settled even mid-call. Left * unset, a working turn's run is taken to be its last. */ trailing?: boolean - structuredActivityUi?: boolean /** Message this run belongs to. Windowing unmounts rows, so a run the reader * opened has to be remembered somewhere that outlives the row. */ disclosureId?: string @@ -105,7 +112,20 @@ export function NativeChatToolRun({ // deciding the row is worth mounting cannot disagree about what draws. const subagentRows = subagentGroups .filter(isRenderableSubagentGroup) - .map((group) => ) + .map((group) => ( + subagentDisclosure.setRosterOpen(disclosureId, open) + : undefined + } + sections={subagentRoster?.sections} + onSetSectionOpen={subagentDisclosure?.setSectionOpen} + /> + )) // Neither a roster nor a background task is tool activity, so both take every // escape below that the tool header does not: a task row outlives the turn // that started it and is the only durable report of how it ended. @@ -130,10 +150,9 @@ export function NativeChatToolRun({ // caller with no turn state, or a turn blocked on the reader's answer, falls // back to the calls themselves. const live = - structuredActivityUi && - (activeTurnIsWorking === true && !askIsActive + activeTurnIsWorking === true && !askIsActive ? trailing !== false - : selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null) + : selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null // One sentence for the whole run, or the command itself when the run is one // call — the reader recognizes `git push` faster than "Ran 1 command". const runSentence = nativeChatToolRunSentence(headerBlocks, { live }) @@ -199,7 +218,6 @@ export function NativeChatToolRun({ // the grouped row visible here made a failed child command look like the // whole response was still running (or had failed) even while collapsed. if ( - structuredActivityUi && expandOverride === false && !(revealedDiff && open) && !live && @@ -237,7 +255,7 @@ export function NativeChatToolRun({ aria-live="polite" data-native-chat-tool-run-state={live ? 'live' : 'settled'} > - {structuredActivityUi && settledHeaderIcon ? ( + {settledHeaderIcon ? ( ) : null} {/* The run in words, in the transcript's own type. Present tense while @@ -278,7 +296,7 @@ export function NativeChatToolRun({ {/* Only a stated success is marked done — see nativeChatToolRunOutcome — and never while live: between two calls nothing is running, and a mark that appeared then would flash on every call. */} - {structuredActivityUi && !live && runSucceeded ? ( + {!live && runSucceeded ? ( ) : null} {latestCallLabel ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx index c8b51e0263e..6565ef35c79 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx @@ -2,7 +2,7 @@ import { NativeChatTranscriptRow, type NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' -import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' +import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window' /** Windowed transcript rows, absolutely positioned inside a full-height spacer. */ @@ -48,3 +48,20 @@ export function NativeChatTranscriptItems({
    ) } + +/** Rows drawn after the live turn's activity, outside the window: few, and the newest there are. */ +export function NativeChatWaitingTranscriptItems({ + slots, + context +}: { + slots: readonly NativeChatTranscriptSlot[] + context: NativeChatTranscriptRowContext +}): React.JSX.Element { + return ( + <> + {slots.map((slot) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 54499e3dc31..d6b862032d2 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -1,10 +1,14 @@ import { memo } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { cn } from '@/lib/utils' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt' import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' +import { NativeChatSubagentSectionHead } from './NativeChatSubagentSectionHead' +import { NativeChatSubagentEntries } from './NativeChatSubagentRun' +import type { NativeChatSubagentDisclosure } from './native-chat-subagent-sections' import type { NativeChatTaskListPredecessors } from './native-chat-task-list-history' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' @@ -13,7 +17,6 @@ import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-t * object so a row's props change only when that row's own slot does. */ export type NativeChatTranscriptRowContext = { expandSignal: boolean - showTurnStatus: boolean revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap expandedTurnIds: ReadonlySet @@ -23,6 +26,7 @@ export type NativeChatTranscriptRowContext = { runtimeContext?: RuntimeFileOperationArgs | null onLinkClick?: CommentMarkdownLinkClickHandler onToggleExpandedTurn: (turnKey: string) => void + subagentDisclosure: NativeChatSubagentDisclosure onScrollMessageToTop: (element: HTMLElement) => void onRevealDiff: (target: NativeChatDiffTarget) => void } @@ -40,6 +44,34 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ slot: NativeChatTranscriptSlot context: NativeChatTranscriptRowContext }): React.JSX.Element { + // A subagent's section is set off from the conversation it sits in. + const sectionClassName = cn( + slot.depth > 0 && 'border-l-2 border-border/60 pl-3', + slot.depth > 1 && 'ml-4' + ) + if (slot.kind === 'subagent') { + return ( +
    + +
    + ) + } + if (slot.kind === 'subagent-entries') { + return ( + + ) + } const { message, turnKey, status, receipt, turnDiff } = slot const predecessors = context.taskListPredecessors.get(message.id) const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined @@ -54,7 +86,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ /> ) : null return ( -
    +
    {/* A turn with no user bubble carries its bar above its first row. */} {slot.statusAbove ? statusRow : null} {receipt ? ( @@ -74,9 +106,10 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ onLinkClick={context.onLinkClick} allowFileUriLinks={context.allowFileUriLinks} deliveryNotice={context.deliveryNotices?.get(message.id)} - structuredActivityUi={context.showTurnStatus} folded={slot.folded} - subagentLabel={slot.subagentLabel} + subagentRoster={slot.subagentRoster} + subagentDisclosure={context.subagentDisclosure} + inSubagentSection={slot.depth > 0} runtimeContext={context.runtimeContext} /> )} diff --git a/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx b/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx deleted file mode 100644 index 59969c1dece..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { translate } from '@/i18n/i18n' - -export function NativeChatTypingIndicatorRow(): React.JSX.Element { - return ( -
    -
    - {[0, 1, 2].map((i) => ( - - ))} -
    -
    - ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatView.test.tsx b/src/renderer/src/components/native-chat/NativeChatView.test.tsx index cc25dabc125..482a8096232 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.test.tsx @@ -35,8 +35,10 @@ function renderResolution( ) } +const notComposing = (): boolean => false + function DraftProbe({ paneKey, sessionId }: { paneKey: string; sessionId: string | null }) { - const { draft, setDraft } = useNativeChatDraft(paneKey) + const { draft, setDraft } = useNativeChatDraft(paneKey, notComposing) return (